diff options
author | Stan Hu <stanhu@gmail.com> | 2017-09-05 11:33:09 -0700 |
---|---|---|
committer | Stan Hu <stanhu@gmail.com> | 2017-09-05 11:33:09 -0700 |
commit | 41e5ec8f74d9909050d54ae957b09a812a398c8e (patch) | |
tree | 8d07352840703d1421e53c2e8a595cd4da049842 | |
parent | f045903541ace5cf4fd3c6e4a05ecfd264c1c621 (diff) | |
parent | 685066cd0e4bb9c2279c1ed43ae445d07c963743 (diff) | |
download | gitlab-ce-41e5ec8f74d9909050d54ae957b09a812a398c8e.tar.gz |
Merge branch 'master' into sh-headless-chrome-support
683 files changed, 23809 insertions, 5764 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9fd42386b95..61c08429c96 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -125,6 +125,7 @@ stages: - export KNAPSACK_GENERATE_REPORT=true - export CACHE_CLASSES=true - cp ${KNAPSACK_SPINACH_SUITE_REPORT_PATH} ${KNAPSACK_REPORT_PATH} + - scripts/gitaly-test-spawn - knapsack spinach "-r rerun" || retry '[[ -e tmp/spinach-rerun.txt ]] && bundle exec spinach -r rerun $(cat tmp/spinach-rerun.txt)' artifacts: expire_in: 31d @@ -207,11 +208,10 @@ update-tests-metadata: - '[[ -z ${TESTS_METADATA_S3_BUCKET} ]] || scripts/sync-reports put $TESTS_METADATA_S3_BUCKET $KNAPSACK_RSPEC_SUITE_REPORT_PATH $KNAPSACK_SPINACH_SUITE_REPORT_PATH' - '[[ -z ${TESTS_METADATA_S3_BUCKET} ]] || scripts/sync-reports put $TESTS_METADATA_S3_BUCKET $FLAKY_RSPEC_SUITE_REPORT_PATH' - rm -f knapsack/${CI_PROJECT_NAME}/*_node_*.json - - rm -f rspec_flaky/${CI_PROJECT_NAME}/all_node_*.json + - rm -f rspec_flaky/${CI_PROJECT_NAME}/*_node_*.json flaky-examples-check: <<: *dedicated-runner - <<: *except-docs image: ruby:2.3-alpine services: [] before_script: [] @@ -226,6 +226,7 @@ flaky-examples-check: - branches except: - master + - /(^docs[\/-].*|.*-docs$)/ artifacts: expire_in: 30d paths: diff --git a/CHANGELOG.md b/CHANGELOG.md index ac0d22ced46..a02b6594fad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 9.5.3 (2017-09-03) + +- [SECURITY] Filter additional secrets from Rails logs. +- [FIXED] Make username update fail if the namespace update fails. !13642 +- [FIXED] Fix failure when issue is authored by a deleted user. !13807 +- [FIXED] Reverts changes made to signin_enabled. !13956 +- [FIXED] Fix Merge when pipeline succeeds button dropdown caret icon horizontal alignment. +- [FIXED] Fixed diff changes bar buttons from showing/hiding whilst scrolling. +- [FIXED] Fix events error importing GitLab projects. +- [FIXED] Fix pipeline trigger via API fails with 500 Internal Server Error in 9.5. +- [FIXED] Fixed fly-out nav flashing in & out. +- [FIXED] Remove closing external issues by reference error. +- [FIXED] Re-allow appearances.description_html to be NULL. +- [CHANGED] Update and fix resolvable note icons for easier recognition. +- [OTHER] Eager load head pipeline projects for MRs index. +- [OTHER] Instrument MergeRequest#fetch_ref. +- [OTHER] Instrument MergeRequest#ensure_ref_fetched. + ## 9.5.2 (2017-08-28) - [FIXED] Fix signing in using LDAP when attribute mapping uses simple strings instead of arrays. diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 7b52f5e5178..0f1a7dfc7c4 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.35.0 +0.37.0 @@ -349,6 +349,8 @@ group :development, :test do gem 'activerecord_sane_schema_dumper', '0.2' gem 'stackprof', '~> 0.2.10', require: false + + gem 'simple_po_parser', '~> 1.1.2', require: false end group :test do @@ -395,7 +397,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 0.31.0', require: 'gitaly' +gem 'gitaly-proto', '~> 0.32.0', require: 'gitaly' gem 'toml-rb', '~> 0.3.15', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 915c6ec550d..44d5187f8a5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -276,7 +276,7 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) gherkin-ruby (0.3.2) - gitaly-proto (0.31.0) + gitaly-proto (0.32.0) google-protobuf (~> 3.1) grpc (~> 1.0) github-linguist (4.7.6) @@ -720,7 +720,7 @@ GEM retriable (1.4.1) rinku (2.0.0) rotp (2.1.2) - rouge (2.2.0) + rouge (2.2.1) rqrcode (0.7.0) chunky_png rqrcode-rails3 (0.1.7) @@ -833,6 +833,7 @@ GEM faraday (~> 0.9) jwt (~> 1.5) multi_json (~> 1.10) + simple_po_parser (1.1.2) simplecov (0.14.1) docile (~> 1.1.0) json (>= 1.8, < 3) @@ -1016,7 +1017,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.2.0) - gitaly-proto (~> 0.31.0) + gitaly-proto (~> 0.32.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.5.1) @@ -1142,6 +1143,7 @@ DEPENDENCIES sidekiq (~> 5.0) sidekiq-cron (~> 0.6.0) sidekiq-limit_fetch (~> 3.4) + simple_po_parser (~> 1.1.2) simplecov (~> 0.14.0) slack-notifier (~> 1.5.1) spinach-rails (~> 0.2.1) diff --git a/README.md b/README.md index 9309922ae39..9ead6d51c5d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master) [![Overall test coverage](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg)](https://gitlab.com/gitlab-org/gitlab-ce/pipelines) +[![Dependency Status](https://gemnasium.com/gitlabhq/gitlabhq.svg)](https://gemnasium.com/gitlabhq/gitlabhq) [![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq) [![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42) [![Gitter](https://badges.gitter.im/gitlabhq/gitlabhq.svg)](https://gitter.im/gitlabhq/gitlabhq?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 78cb3def879..8acddd6194c 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -5,7 +5,7 @@ const Api = { groupPath: '/api/:version/groups/:id.json', namespacesPath: '/api/:version/namespaces.json', groupProjectsPath: '/api/:version/groups/:id/projects.json', - projectsPath: '/api/:version/projects.json?simple=true', + projectsPath: '/api/:version/projects.json', labelsPath: '/:namespace_path/:project_path/labels', licensePath: '/api/:version/templates/licenses/:key', gitignorePath: '/api/:version/templates/gitignores/:key', @@ -58,6 +58,7 @@ const Api = { const defaults = { search: query, per_page: 20, + simple: true, }; if (gon.current_user_id) { diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index cfab6c40b34..4d2d4db7c0e 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -2,17 +2,17 @@ import AccessorUtilities from './lib/utils/accessor'; window.Autosave = (function() { - function Autosave(field, key) { + function Autosave(field, key, resource) { this.field = field; this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); - + this.resource = resource; if (key.join != null) { - key = key.join("/"); + key = key.join('/'); } - this.key = "autosave/" + key; - this.field.data("autosave", this); + this.key = 'autosave/' + key; + this.field.data('autosave', this); this.restore(); - this.field.on("input", (function(_this) { + this.field.on('input', (function(_this) { return function() { return _this.save(); }; @@ -29,7 +29,17 @@ window.Autosave = (function() { if ((text != null ? text.length : void 0) > 0) { this.field.val(text); } - return this.field.trigger("input"); + if (!this.resource && this.resource !== 'issue') { + this.field.trigger('input'); + } else { + // v-model does not update with jQuery trigger + // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137 + const event = new Event('change', { bubbles: true, cancelable: false }); + const field = this.field.get(0); + if (field) { + field.dispatchEvent(event); + } + } }; Autosave.prototype.save = function() { diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 097f79a250a..22fa1f2a609 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -109,6 +109,7 @@ class AwardsHandler { } $thumbsBtn.toggleClass('disabled', $userAuthored); + $thumbsBtn.prop('disabled', $userAuthored); } // Create the emoji menu with the first category of emojis. @@ -234,14 +235,33 @@ class AwardsHandler { } addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { + const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length; + + if (gl.utils.isInIssuePage() && !isMainAwardsBlock) { + const id = votesBlock.attr('id').replace('note_', ''); + + $('.emoji-menu').removeClass('is-visible'); + $('.js-add-award.is-active').removeClass('is-active'); + const toggleAwardEvent = new CustomEvent('toggleAward', { + detail: { + awardName: emoji, + noteId: id, + }, + }); + + document.querySelector('.js-vue-notes-event').dispatchEvent(toggleAwardEvent); + } + const normalizedEmoji = this.emoji.normalizeEmojiName(emoji); const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent(); + this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => { this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality); return typeof callback === 'function' ? callback() : undefined; }); + $('.emoji-menu').removeClass('is-visible'); - $('.js-add-award.is-active').removeClass('is-active'); + return $('.js-add-award.is-active').removeClass('is-active'); } addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) { @@ -268,6 +288,14 @@ class AwardsHandler { } getVotesBlock() { + if (gl.utils.isInIssuePage()) { + const $el = $('.js-add-award.is-active').closest('.note.timeline-entry'); + + if ($el.length) { + return $el; + } + } + const currentBlock = $('.js-awards-block.current'); let resultantVotesBlock = currentBlock; if (currentBlock.length === 0) { diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js index bc693616460..79702c54852 100644 --- a/app/assets/javascripts/behaviors/quick_submit.js +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -44,7 +44,10 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => { if (!$submitButton.attr('disabled')) { $submitButton.trigger('click', [e]); - $submitButton.disable(); + + if (!gl.utils.isInIssuePage()) { + $submitButton.disable(); + } } }); diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index 6db8b3afbef..768453b28f1 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -2,3 +2,4 @@ import 'underscore'; import './polyfills'; import './jquery'; import './bootstrap'; +import './vue'; diff --git a/app/assets/javascripts/vue_shared/common_vue.js b/app/assets/javascripts/commons/vue.js index eb2a6071fda..8b62d78c043 100644 --- a/app/assets/javascripts/vue_shared/common_vue.js +++ b/app/assets/javascripts/commons/vue.js @@ -1,5 +1,4 @@ import Vue from 'vue'; -import './vue_resource_interceptor'; if (process.env.NODE_ENV !== 'production') { Vue.config.productionTip = false; 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 c37249c060a..06ce84d7599 100644 --- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js +++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js @@ -21,11 +21,13 @@ const DiffNoteAvatars = Vue.extend({ }, template: ` <div class="diff-comment-avatar-holders" + :class="discussionClassName" v-show="notesCount !== 0"> <div v-if="!isVisible"> <!-- FIXME: Pass an alt attribute here for accessibility --> <user-avatar-image v-for="note in notesSubset" + :key="note.id" class="diff-comment-avatar js-diff-comment-avatar" @click.native="clickedAvatar($event)" :img-src="note.authorAvatar" @@ -68,7 +70,8 @@ const DiffNoteAvatars = Vue.extend({ }); }); }, - destroyed() { + beforeDestroy() { + this.addNoCommentClass(); $(document).off('toggle.comments'); }, watch: { @@ -85,6 +88,9 @@ const DiffNoteAvatars = Vue.extend({ }, }, computed: { + discussionClassName() { + return `js-diff-avatars-${this.discussionId}`; + }, notesSubset() { let notes = []; diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js index 5decfc1dc01..0863c3406bd 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js @@ -32,6 +32,10 @@ $(() => { const tmpApp = new tmp().$mount(); $(this).replaceWith(tmpApp.$el); + $(tmpApp.$el).one('remove.vue', () => { + tmpApp.$destroy(); + tmpApp.$el.remove(); + }); }); const $components = $(COMPONENT_SELECTOR).filter(function () { diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index c70a17104fd..3dec4de06ec 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -99,7 +99,7 @@ import initChangesDropdown from './init_changes_dropdown'; path = page.split(':'); shortcut_handler = null; - $('.js-gfm-input').each((i, el) => { + $('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => { const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); const enableGFM = gl.utils.convertPermissionToBoolean(el.dataset.supportsAutocomplete); gfm.setup($(el), { @@ -172,7 +172,6 @@ import initChangesDropdown from './init_changes_dropdown'; shortcut_handler = new ShortcutsIssuable(); new ZenMode(); initIssuableSidebar(); - initNotes(); break; case 'dashboard:milestones:index': new ProjectSelect(); diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 6d19a6d9b3a..975903159be 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -128,7 +128,7 @@ window.DropzoneInput = (function() { // removeAllFiles(true) stops uploading files (if any) // and remove them from dropzone files queue. $cancelButton.on('click', (e) => { - const target = e.target.closest('form').querySelector('.div-dropzone'); + const target = e.target.closest('.js-main-target-form').querySelector('.div-dropzone'); e.preventDefault(); e.stopPropagation(); @@ -140,7 +140,7 @@ window.DropzoneInput = (function() { // and add that files to the dropzone files queue again. // addFile() adds file to dropzone files queue and upload it. $retryLink.on('click', (e) => { - const dropzoneInstance = Dropzone.forElement(e.target.closest('form').querySelector('.div-dropzone')); + const dropzoneInstance = Dropzone.forElement(e.target.closest('.js-main-target-form').querySelector('.div-dropzone')); const failedFiles = dropzoneInstance.files; e.preventDefault(); diff --git a/app/assets/javascripts/feature_highlight/feature_highlight.js b/app/assets/javascripts/feature_highlight/feature_highlight.js new file mode 100644 index 00000000000..800ca05cd11 --- /dev/null +++ b/app/assets/javascripts/feature_highlight/feature_highlight.js @@ -0,0 +1,61 @@ +import Cookies from 'js-cookie'; +import _ from 'underscore'; +import { + getCookieName, + getSelector, + hidePopover, + setupDismissButton, + mouseenter, + mouseleave, +} from './feature_highlight_helper'; + +export const setupFeatureHighlightPopover = (id, debounceTimeout = 300) => { + const $selector = $(getSelector(id)); + const $parent = $selector.parent(); + const $popoverContent = $parent.siblings('.feature-highlight-popover-content'); + const hideOnScroll = hidePopover.bind($selector); + const debouncedMouseleave = _.debounce(mouseleave, debounceTimeout); + + $selector + // Setup popover + .data('content', $popoverContent.prop('outerHTML')) + .popover({ + html: true, + // Override the existing template to add custom CSS classes + template: ` + <div class="popover feature-highlight-popover" role="tooltip"> + <div class="arrow"></div> + <div class="popover-content"></div> + </div> + `, + }) + .on('mouseenter', mouseenter) + .on('mouseleave', debouncedMouseleave) + .on('inserted.bs.popover', setupDismissButton) + .on('show.bs.popover', () => { + window.addEventListener('scroll', hideOnScroll); + }) + .on('hide.bs.popover', () => { + window.removeEventListener('scroll', hideOnScroll); + }) + // Display feature highlight + .removeAttr('disabled'); +}; + +export const shouldHighlightFeature = (id) => { + const element = document.querySelector(getSelector(id)); + const previouslyDismissed = Cookies.get(getCookieName(id)) === 'true'; + + return element && !previouslyDismissed; +}; + +export const highlightFeatures = (highlightOrder) => { + const featureId = highlightOrder.find(shouldHighlightFeature); + + if (featureId) { + setupFeatureHighlightPopover(featureId); + return true; + } + + return false; +}; diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js new file mode 100644 index 00000000000..9f741355cd7 --- /dev/null +++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js @@ -0,0 +1,57 @@ +import Cookies from 'js-cookie'; + +export const getCookieName = cookieId => `feature-highlighted-${cookieId}`; +export const getSelector = highlightId => `.js-feature-highlight[data-highlight=${highlightId}]`; + +export const showPopover = function showPopover() { + if (this.hasClass('js-popover-show')) { + return false; + } + this.popover('show'); + this.addClass('disable-animation js-popover-show'); + + return true; +}; + +export const hidePopover = function hidePopover() { + if (!this.hasClass('js-popover-show')) { + return false; + } + this.popover('hide'); + this.removeClass('disable-animation js-popover-show'); + + return true; +}; + +export const dismiss = function dismiss(cookieId) { + Cookies.set(getCookieName(cookieId), true); + hidePopover.call(this); + this.hide(); +}; + +export const mouseleave = function mouseleave() { + if (!$('.popover:hover').length > 0) { + const $featureHighlight = $(this); + hidePopover.call($featureHighlight); + } +}; + +export const mouseenter = function mouseenter() { + const $featureHighlight = $(this); + + const showedPopover = showPopover.call($featureHighlight); + if (showedPopover) { + $('.popover') + .on('mouseleave', mouseleave.bind($featureHighlight)); + } +}; + +export const setupDismissButton = function setupDismissButton() { + const popoverId = this.getAttribute('aria-describedby'); + const cookieId = this.dataset.highlight; + const $popover = $(this); + const dismissWrapper = dismiss.bind($popover, cookieId); + + $(`#${popoverId} .dismiss-feature-highlight`) + .on('click', dismissWrapper); +}; diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_options.js b/app/assets/javascripts/feature_highlight/feature_highlight_options.js new file mode 100644 index 00000000000..fd48f2e87cc --- /dev/null +++ b/app/assets/javascripts/feature_highlight/feature_highlight_options.js @@ -0,0 +1,12 @@ +import { highlightFeatures } from './feature_highlight'; +import bp from '../breakpoints'; + +const highlightOrder = ['issue-boards']; + +export default function domContentLoaded(order) { + if (bp.getBreakpointSize() === 'lg') { + highlightFeatures(order); + } +} + +document.addEventListener('DOMContentLoaded', domContentLoaded.bind(this, highlightOrder)); diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 81697af189b..063155a167a 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -12,6 +12,7 @@ let sidebar; export const mousePos = []; export const setSidebar = (el) => { sidebar = el; }; +export const getOpenMenu = () => currentOpenMenu; export const setOpenMenu = (menu = null) => { currentOpenMenu = menu; }; export const slope = (a, b) => (b.y - a.y) / (b.x - a.x); @@ -141,6 +142,14 @@ export const documentMouseMove = (e) => { if (mousePos.length > 6) mousePos.shift(); }; +export const subItemsMouseLeave = (relatedTarget) => { + clearTimeout(timeoutId); + + if (!relatedTarget.closest(`.${IS_OVER_CLASS}`)) { + hideMenu(currentOpenMenu); + } +}; + export default () => { sidebar = document.querySelector('.nav-sidebar'); @@ -162,10 +171,7 @@ export default () => { const subItems = el.querySelector('.sidebar-sub-level-items'); if (subItems) { - subItems.addEventListener('mouseleave', () => { - clearTimeout(timeoutId); - hideMenu(currentOpenMenu); - }); + subItems.addEventListener('mouseleave', e => subItemsMouseLeave(e.relatedTarget)); } el.addEventListener('mouseenter', e => mouseEnterTopItems(e.currentTarget)); diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index b62acfcd445..d65bbc0d808 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -486,7 +486,7 @@ GitLabDropdown = (function() { GitLabDropdown.prototype.shouldPropagate = function(e) { var $target; - if (this.options.multiSelect) { + if (this.options.multiSelect || this.options.shouldPropagate === false) { $target = $(e.target); if ($target && !$target.hasClass('dropdown-menu-close') && !$target.hasClass('dropdown-menu-close-icon') && @@ -546,10 +546,10 @@ GitLabDropdown = (function() { }; GitLabDropdown.prototype.positionMenuAbove = function() { - var $button = $(this.el); var $menu = this.dropdown.find('.dropdown-menu'); - $menu.css('top', ($button.height() + $menu.height()) * -1); + $menu.css('top', 'initial'); + $menu.css('bottom', '100%'); }; GitLabDropdown.prototype.hidden = function(e) { @@ -698,7 +698,7 @@ GitLabDropdown = (function() { GitLabDropdown.prototype.noResults = function() { var html; - return html = "<li class='dropdown-menu-empty-link'> <a href='#' class='is-focused'> No matching results. </a> </li>"; + return html = '<li class="dropdown-menu-empty-link"><a href="#" class="is-focused">No matching results</a></li>'; }; GitLabDropdown.prototype.rowClicked = function(el) { diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 3f848e0859b..470c39c6f76 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -10,8 +10,6 @@ import ZenMode from './zen_mode'; (function() { this.IssuableForm = (function() { - IssuableForm.prototype.issueMoveConfirmMsg = 'Are you sure you want to move this issue to another project?'; - IssuableForm.prototype.wipRegex = /^\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i; function IssuableForm(form) { @@ -26,7 +24,6 @@ import ZenMode from './zen_mode'; new ZenMode(); this.titleField = this.form.find("input[name*='[title]']"); this.descriptionField = this.form.find("textarea[name*='[description]']"); - this.issueMoveField = this.form.find("#move_to_project_id"); if (!(this.titleField.length && this.descriptionField.length)) { return; } @@ -34,7 +31,6 @@ import ZenMode from './zen_mode'; this.form.on("submit", this.handleSubmit); this.form.on("click", ".btn-cancel", this.resetAutosave); this.initWip(); - this.initMoveDropdown(); $issuableDueDate = $('#issuable-due-date'); if ($issuableDueDate.length) { calendar = new Pikaday({ @@ -56,12 +52,6 @@ import ZenMode from './zen_mode'; }; IssuableForm.prototype.handleSubmit = function() { - var fieldId = (this.issueMoveField != null) ? this.issueMoveField.val() : null; - if ((parseInt(fieldId, 10) || 0) > 0) { - if (!confirm(this.issueMoveConfirmMsg)) { - return false; - } - } return this.resetAutosave(); }; @@ -113,48 +103,6 @@ import ZenMode from './zen_mode'; return this.titleField.val("WIP: " + (this.titleField.val())); }; - IssuableForm.prototype.initMoveDropdown = function() { - var $moveDropdown, pageSize; - $moveDropdown = $('.js-move-dropdown'); - if ($moveDropdown.length) { - pageSize = $moveDropdown.data('page-size'); - return $('.js-move-dropdown').select2({ - ajax: { - url: $moveDropdown.data('projects-url'), - quietMillis: 125, - data: function(term, page, context) { - return { - search: term, - offset_id: context - }; - }, - results: function(data) { - var context, - more; - - if (data.length >= pageSize) - more = true; - - if (data[data.length - 1]) - context = data[data.length - 1].id; - - return { - results: data, - more: more, - context: context - }; - } - }, - formatResult: function(project) { - return project.name_with_namespace; - }, - formatSelection: function(project) { - return project.name_with_namespace; - } - }); - } - }; - return IssuableForm; })(); }).call(window); diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 2bee4fb045a..7c4f4da6127 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -42,7 +42,7 @@ class Issue { initIssueBtnEventListeners() { const issueFailMessage = 'Unable to update this issue at this time.'; - return $(document).on('click', 'a.btn-close, a.btn-reopen', (e) => { + return $(document).on('click', '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', (e) => { var $button, shouldSubmit, url; e.preventDefault(); e.stopImmediatePropagation(); @@ -66,12 +66,11 @@ class Issue { const projectIssuesCounter = $('.issue_counter'); if ('id' in data) { - $(document).trigger('issuable:change'); - const isClosed = $button.hasClass('btn-close'); isClosedBadge.toggleClass('hidden', !isClosed); isOpenBadge.toggleClass('hidden', isClosed); + $(document).trigger('issuable:change', isClosed); this.toggleCloseReopenButton(isClosed); let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, '')); @@ -121,7 +120,7 @@ class Issue { static submitNoteForm(form) { var noteText; noteText = form.find("textarea.js-note-text").val(); - if (noteText.trim().length > 0) { + if (noteText && noteText.trim().length > 0) { return form.submit(); } } diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index efae112923d..e115ee40219 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -17,10 +17,6 @@ export default { required: true, type: String, }, - canMove: { - required: true, - type: Boolean, - }, canUpdate: { required: true, type: Boolean, @@ -80,11 +76,11 @@ export default { type: Boolean, required: true, }, - markdownPreviewUrl: { + markdownPreviewPath: { type: String, required: true, }, - markdownDocs: { + markdownDocsPath: { type: String, required: true, }, @@ -96,10 +92,6 @@ export default { type: String, required: true, }, - projectsAutocompleteUrl: { - type: String, - required: true, - }, }, data() { const store = new Store({ @@ -142,7 +134,6 @@ export default { confidential: this.isConfidential, description: this.state.descriptionText, lockedWarningVisible: false, - move_to_project_id: 0, updateLoading: false, }); } @@ -151,16 +142,6 @@ export default { this.showForm = false; }, updateIssuable() { - const canPostUpdate = this.store.formState.move_to_project_id !== 0 ? - confirm('Are you sure you want to move this issue to another project?') : true; // eslint-disable-line no-alert - - if (!canPostUpdate) { - this.store.setFormState({ - updateLoading: false, - }); - return; - } - this.service.updateIssuable(this.store.formState) .then(res => res.json()) .then((data) => { @@ -239,14 +220,12 @@ export default { <form-component v-if="canUpdate && showForm" :form-state="formState" - :can-move="canMove" :can-destroy="canDestroy" :issuable-templates="issuableTemplates" - :markdown-docs="markdownDocs" - :markdown-preview-url="markdownPreviewUrl" + :markdown-docs-path="markdownDocsPath" + :markdown-preview-path="markdownPreviewPath" :project-path="projectPath" :project-namespace="projectNamespace" - :projects-autocomplete-url="projectsAutocompleteUrl" /> <div v-else> <title-component diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 27b1b814f9a..dc902eefc5f 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -10,11 +10,11 @@ type: Object, required: true, }, - markdownPreviewUrl: { + markdownPreviewPath: { type: String, required: true, }, - markdownDocs: { + markdownDocsPath: { type: String, required: true, }, @@ -36,8 +36,8 @@ Description </label> <markdown-field - :markdown-preview-url="markdownPreviewUrl" - :markdown-docs="markdownDocs"> + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath"> <textarea id="issue-description" class="note-textarea js-gfm-input js-autosize markdown-area" diff --git a/app/assets/javascripts/issue_show/components/fields/project_move.vue b/app/assets/javascripts/issue_show/components/fields/project_move.vue deleted file mode 100644 index 7bf2be8b28a..00000000000 --- a/app/assets/javascripts/issue_show/components/fields/project_move.vue +++ /dev/null @@ -1,83 +0,0 @@ -<script> - import tooltip from '../../../vue_shared/directives/tooltip'; - - export default { - directives: { - tooltip, - }, - props: { - formState: { - type: Object, - required: true, - }, - projectsAutocompleteUrl: { - type: String, - required: true, - }, - }, - mounted() { - const $moveDropdown = $(this.$refs['move-dropdown']); - - $moveDropdown.select2({ - ajax: { - url: this.projectsAutocompleteUrl, - quietMillis: 125, - data(term, page, context) { - return { - search: term, - offset_id: context, - }; - }, - results(data) { - const more = data.length >= 50; - const context = data[data.length - 1] ? data[data.length - 1].id : null; - - return { - results: data, - more, - context, - }; - }, - }, - formatResult(project) { - return project.name_with_namespace; - }, - formatSelection(project) { - return project.name_with_namespace; - }, - }) - .on('change', (e) => { - this.formState.move_to_project_id = parseInt(e.target.value, 10); - }); - }, - beforeDestroy() { - $(this.$refs['move-dropdown']).select2('destroy'); - }, - }; -</script> - -<template> - <fieldset> - <label - for="issuable-move" - class="sr-only"> - Move - </label> - <div class="issuable-form-select-holder append-right-5"> - <input - ref="move-dropdown" - type="hidden" - id="issuable-move" - data-placeholder="Move to a different project" /> - </div> - <span - v-tooltip - data-placement="auto top" - title="Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location."> - <i - class="fa fa-question-circle" - aria-hidden="true"> - </i> - </span> - </fieldset> -</template> diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index 76ec3dc9a5d..6a2dd502fe2 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -4,15 +4,10 @@ import descriptionField from './fields/description.vue'; import editActions from './edit_actions.vue'; import descriptionTemplate from './fields/description_template.vue'; - import projectMove from './fields/project_move.vue'; import confidentialCheckbox from './fields/confidential_checkbox.vue'; export default { props: { - canMove: { - type: Boolean, - required: true, - }, canDestroy: { type: Boolean, required: true, @@ -26,11 +21,11 @@ required: false, default: () => [], }, - markdownPreviewUrl: { + markdownPreviewPath: { type: String, required: true, }, - markdownDocs: { + markdownDocsPath: { type: String, required: true, }, @@ -42,10 +37,6 @@ type: String, required: true, }, - projectsAutocompleteUrl: { - type: String, - required: true, - }, }, components: { lockedWarning, @@ -53,7 +44,6 @@ descriptionField, descriptionTemplate, editActions, - projectMove, confidentialCheckbox, }, computed: { @@ -89,14 +79,10 @@ </div> <description-field :form-state="formState" - :markdown-preview-url="markdownPreviewUrl" - :markdown-docs="markdownDocs" /> + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" /> <confidential-checkbox :form-state="formState" /> - <project-move - v-if="canMove" - :form-state="formState" - :projects-autocomplete-url="projectsAutocompleteUrl" /> <edit-actions :form-state="formState" :can-destroy="canDestroy" /> diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index ad8cb6465e2..8053ef57e6c 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -28,7 +28,6 @@ document.addEventListener('DOMContentLoaded', () => { props: { canUpdate: this.canUpdate, canDestroy: this.canDestroy, - canMove: this.canMove, endpoint: this.endpoint, issuableRef: this.issuableRef, initialTitleHtml: this.initialTitleHtml, @@ -37,11 +36,10 @@ document.addEventListener('DOMContentLoaded', () => { initialDescriptionText: this.initialDescriptionText, issuableTemplates: this.issuableTemplates, isConfidential: this.isConfidential, - markdownPreviewUrl: this.markdownPreviewUrl, - markdownDocs: this.markdownDocs, + markdownPreviewPath: this.markdownPreviewPath, + markdownDocsPath: this.markdownDocsPath, projectPath: this.projectPath, projectNamespace: this.projectNamespace, - projectsAutocompleteUrl: this.projectsAutocompleteUrl, updatedAt: this.updatedAt, updatedByName: this.updatedByName, updatedByPath: this.updatedByPath, diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index 0c8bd6f1cc3..f4639e9ed2a 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -6,7 +6,6 @@ export default class Store { confidential: false, description: '', lockedWarningVisible: false, - move_to_project_id: 0, updateLoading: false, }; } diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index b8f4f4eaba3..b8bebe1894f 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -27,6 +27,13 @@ } }; + w.gl.utils.isInIssuePage = () => { + const page = gl.utils.getPagePath(1); + const action = gl.utils.getPagePath(2); + + return page === 'issues' && action === 'show'; + }; + w.gl.utils.ajaxGet = function(url) { return $.ajax({ type: "GET", @@ -167,11 +174,12 @@ }; gl.utils.scrollToElement = function($el) { - var top = $el.offset().top; - gl.mrTabsHeight = gl.mrTabsHeight || $('.merge-request-tabs').height(); + const top = $el.offset().top; + const mrTabsHeight = $('.merge-request-tabs').height() || 0; + const headerHeight = $('.navbar-gitlab').height() || 0; return $('body, html').animate({ - scrollTop: top - (gl.mrTabsHeight) + scrollTop: top - mrTabsHeight - headerHeight, }, 200); }; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 6d7c7e3c930..f14458c8d41 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -102,6 +102,7 @@ import './label_manager'; import './labels'; import './labels_select'; import './layout_nav'; +import './feature_highlight/feature_highlight_options'; import LazyLoader from './lazy_loader'; import './line_highlighter'; import './logo'; @@ -131,6 +132,7 @@ import './project_new'; import './project_select'; import './project_show'; import './project_variables'; +import './projects_dropdown'; import './projects_list'; import './syntax_highlight'; import './render_math'; @@ -248,7 +250,10 @@ $(function () { // Initialize popovers $body.popover({ selector: '[data-toggle="popover"]', - trigger: 'focus' + trigger: 'focus', + // set the viewport to the main content, excluding the navigation bar, so + // the navigation can't overlap the popover + viewport: '.page-with-sidebar' }); $('.trigger-submit').on('change', function () { return $(this).parents('form').submit(); diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index 6f6da9e1463..9c785f4ada8 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -3,11 +3,12 @@ import GraphLegend from './graph/legend.vue'; import GraphFlag from './graph/flag.vue'; import GraphDeployment from './graph/deployment.vue'; + import monitoringPaths from './monitoring_paths.vue'; import MonitoringMixin from '../mixins/monitoring_mixins'; import eventHub from '../event_hub'; import measurements from '../utils/measurements'; - import { formatRelevantDigits } from '../../lib/utils/number_utils'; import { timeScaleFormat } from '../utils/date_time_formatters'; + import createTimeSeries from '../utils/multiple_time_series'; import bp from '../../breakpoints'; const bisectDate = d3.bisector(d => d.time).left; @@ -36,32 +37,29 @@ data() { return { + baseGraphHeight: 450, + baseGraphWidth: 600, graphHeight: 450, graphWidth: 600, graphHeightOffset: 120, - xScale: {}, - yScale: {}, margin: {}, - data: [], unitOfDisplay: '', areaColorRgb: '#8fbce8', lineColorRgb: '#1f78d1', yAxisLabel: '', legendTitle: '', reducedDeploymentData: [], - area: '', - line: '', measurements: measurements.large, currentData: { time: new Date(), value: 0, }, - currentYCoordinate: 0, + currentDataIndex: 0, currentXCoordinate: 0, currentFlagPosition: 0, - metricUsage: '', showFlag: false, showDeployInfo: true, + timeSeries: [], }; }, @@ -69,16 +67,17 @@ GraphLegend, GraphFlag, GraphDeployment, + monitoringPaths, }, computed: { outterViewBox() { - return `0 0 ${this.graphWidth} ${this.graphHeight}`; + return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`; }, innerViewBox() { - if ((this.graphWidth - 150) > 0) { - return `0 0 ${this.graphWidth - 150} ${this.graphHeight}`; + if ((this.baseGraphWidth - 150) > 0) { + return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`; } return '0 0 0 0'; }, @@ -89,7 +88,7 @@ paddingBottomRootSvg() { return { - paddingBottom: `${(Math.ceil(this.graphHeight * 100) / this.graphWidth) || 0}%`, + paddingBottom: `${(Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth) || 0}%`, }; }, }, @@ -104,17 +103,16 @@ this.margin = measurements.small.margin; this.measurements = measurements.small; } - this.data = query.result[0].values; this.unitOfDisplay = query.unit || ''; this.yAxisLabel = this.graphData.y_label || 'Values'; this.legendTitle = query.label || 'Average'; this.graphWidth = this.$refs.baseSvg.clientWidth - this.margin.left - this.margin.right; this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; - if (this.data !== undefined) { - this.renderAxesPaths(); - this.formatDeployments(); - } + this.baseGraphHeight = this.graphHeight; + this.baseGraphWidth = this.graphWidth; + this.renderAxesPaths(); + this.formatDeployments(); }, handleMouseOverGraph(e) { @@ -123,16 +121,17 @@ point.y = e.clientY; point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse()); point.x = point.x += 7; - const timeValueOverlay = this.xScale.invert(point.x); - const overlayIndex = bisectDate(this.data, timeValueOverlay, 1); - const d0 = this.data[overlayIndex - 1]; - const d1 = this.data[overlayIndex]; + const firstTimeSeries = this.timeSeries[0]; + const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x); + const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1); + const d0 = firstTimeSeries.values[overlayIndex - 1]; + const d1 = firstTimeSeries.values[overlayIndex]; if (d0 === undefined || d1 === undefined) return; const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay; this.currentData = evalTime ? d1 : d0; - this.currentXCoordinate = Math.floor(this.xScale(this.currentData.time)); + this.currentDataIndex = evalTime ? overlayIndex : (overlayIndex - 1); + this.currentXCoordinate = Math.floor(firstTimeSeries.timeSeriesScaleX(this.currentData.time)); const currentDeployXPos = this.mouseOverDeployInfo(point.x); - this.currentYCoordinate = this.yScale(this.currentData.value); if (this.currentXCoordinate > (this.graphWidth - 200)) { this.currentFlagPosition = this.currentXCoordinate - 103; @@ -145,17 +144,25 @@ } else { this.showFlag = true; } - - this.metricUsage = `${formatRelevantDigits(this.currentData.value)} ${this.unitOfDisplay}`; }, renderAxesPaths() { + this.timeSeries = createTimeSeries(this.graphData.queries[0].result, + this.graphWidth, + this.graphHeight, + this.graphHeightOffset); + + if (this.timeSeries.length > 3) { + this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20; + } + const axisXScale = d3.time.scale() .range([0, this.graphWidth]); - this.yScale = d3.scale.linear() + const axisYScale = d3.scale.linear() .range([this.graphHeight - this.graphHeightOffset, 0]); - axisXScale.domain(d3.extent(this.data, d => d.time)); - this.yScale.domain([0, d3.max(this.data.map(d => d.value))]); + + axisXScale.domain(d3.extent(this.timeSeries[0].values, d => d.time)); + axisYScale.domain([0, d3.max(this.timeSeries[0].values.map(d => d.value))]); const xAxis = d3.svg.axis() .scale(axisXScale) @@ -164,7 +171,7 @@ .orient('bottom'); const yAxis = d3.svg.axis() - .scale(this.yScale) + .scale(axisYScale) .ticks(measurements.yTicks) .orient('left'); @@ -180,25 +187,6 @@ .attr('class', 'axis-tick'); } // Avoid adding the class to the first tick, to prevent coloring }); // This will select all of the ticks once they're rendered - - this.xScale = d3.time.scale() - .range([0, this.graphWidth - 70]); - - this.xScale.domain(d3.extent(this.data, d => d.time)); - - const areaFunction = d3.svg.area() - .x(d => this.xScale(d.time)) - .y0(this.graphHeight - this.graphHeightOffset) - .y1(d => this.yScale(d.value)) - .interpolate('linear'); - - const lineFunction = d3.svg.line() - .x(d => this.xScale(d.time)) - .y(d => this.yScale(d.value)); - - this.line = lineFunction(this.data); - - this.area = areaFunction(this.data); }, }, @@ -245,30 +233,25 @@ :graph-height="graphHeight" :margin="margin" :measurements="measurements" - :area-color-rgb="areaColorRgb" :legend-title="legendTitle" :y-axis-label="yAxisLabel" - :metric-usage="metricUsage" + :time-series="timeSeries" + :unit-of-display="unitOfDisplay" + :current-data-index="currentDataIndex" /> <svg class="graph-data" :viewBox="innerViewBox" ref="graphData"> - <path - class="metric-area" - :d="area" - :fill="areaColorRgb" - transform="translate(-5, 20)"> - </path> - <path - class="metric-line" - :d="line" - :stroke="lineColorRgb" - fill="none" - stroke-width="2" - transform="translate(-5, 20)"> - </path> - <graph-deployment + <monitoring-paths + v-for="(path, index) in timeSeries" + :key="index" + :generated-line-path="path.linePath" + :generated-area-path="path.areaPath" + :line-color="path.lineColor" + :area-color="path.areaColor" + /> + <monitoring-deployment :show-deploy-info="showDeployInfo" :deployment-data="reducedDeploymentData" :graph-height="graphHeight" @@ -277,7 +260,6 @@ <graph-flag v-if="showFlag" :current-x-coordinate="currentXCoordinate" - :current-y-coordinate="currentYCoordinate" :current-data="currentData" :current-flag-position="currentFlagPosition" :graph-height="graphHeight" diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue index c4d4647d240..a98e3d06c18 100644 --- a/app/assets/javascripts/monitoring/components/graph/flag.vue +++ b/app/assets/javascripts/monitoring/components/graph/flag.vue @@ -7,10 +7,6 @@ type: Number, required: true, }, - currentYCoordinate: { - type: Number, - required: true, - }, currentFlagPosition: { type: Number, required: true, @@ -60,16 +56,7 @@ :y2="calculatedHeight" transform="translate(-5, 20)"> </line> - <circle - class="circle-metric" - :fill="circleColorRgb" - stroke="#000" - :cx="currentXCoordinate" - :cy="currentYCoordinate" - r="5" - transform="translate(-5, 20)"> - </circle> - <svg + <svg class="rect-text-metric" :x="currentFlagPosition" y="0"> diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue index d08f9cbffd4..a43dad8e601 100644 --- a/app/assets/javascripts/monitoring/components/graph/legend.vue +++ b/app/assets/javascripts/monitoring/components/graph/legend.vue @@ -1,4 +1,6 @@ <script> + import { formatRelevantDigits } from '../../../lib/utils/number_utils'; + export default { props: { graphWidth: { @@ -17,10 +19,6 @@ type: Object, required: true, }, - areaColorRgb: { - type: String, - required: true, - }, legendTitle: { type: String, required: true, @@ -29,15 +27,25 @@ type: String, required: true, }, - metricUsage: { + timeSeries: { + type: Array, + required: true, + }, + unitOfDisplay: { type: String, required: true, }, + currentDataIndex: { + type: Number, + required: true, + }, }, data() { return { yLabelWidth: 0, yLabelHeight: 0, + seriesXPosition: 0, + metricUsageXPosition: 0, }; }, computed: { @@ -63,10 +71,28 @@ yPosition() { return ((this.graphHeight - this.margin.top) + this.measurements.axisLabelLineOffset) || 0; }, + + }, + methods: { + translateLegendGroup(index) { + return `translate(0, ${12 * (index)})`; + }, + + formatMetricUsage(series) { + return `${formatRelevantDigits(series.values[this.currentDataIndex].value)} ${this.unitOfDisplay}`; + }, }, mounted() { this.$nextTick(() => { const bbox = this.$refs.ylabel.getBBox(); + this.metricUsageXPosition = 0; + this.seriesXPosition = 0; + if (this.$refs.legendTitleSvg != null) { + this.seriesXPosition = this.$refs.legendTitleSvg[0].getBBox().width; + } + if (this.$refs.seriesTitleSvg != null) { + this.metricUsageXPosition = this.$refs.seriesTitleSvg[0].getBBox().width; + } this.yLabelWidth = bbox.width + 10; // Added some padding this.yLabelHeight = bbox.height + 5; }); @@ -121,24 +147,33 @@ dy=".35em"> Time </text> - <rect - :fill="areaColorRgb" - :width="measurements.legends.width" - :height="measurements.legends.height" - x="20" - :y="graphHeight - measurements.legendOffset"> - </rect> - <text - class="text-metric-title" - x="50" - :y="graphHeight - 25"> - {{legendTitle}} - </text> - <text - class="text-metric-usage" - x="50" - :y="graphHeight - 10"> - {{metricUsage}} - </text> + <g class="legend-group" + v-for="(series, index) in timeSeries" + :key="index" + :transform="translateLegendGroup(index)"> + <rect + :fill="series.areaColor" + :width="measurements.legends.width" + :height="measurements.legends.height" + x="20" + :y="graphHeight - measurements.legendOffset"> + </rect> + <text + v-if="timeSeries.length > 1" + class="legend-metric-title" + ref="legendTitleSvg" + x="38" + :y="graphHeight - 30"> + {{legendTitle}} Series {{index + 1}} {{formatMetricUsage(series)}} + </text> + <text + v-else + class="legend-metric-title" + ref="legendTitleSvg" + x="38" + :y="graphHeight - 30"> + {{legendTitle}} {{formatMetricUsage(series)}} + </text> + </g> </g> </template> diff --git a/app/assets/javascripts/monitoring/components/monitoring_paths.vue b/app/assets/javascripts/monitoring/components/monitoring_paths.vue new file mode 100644 index 00000000000..043f1bf66bb --- /dev/null +++ b/app/assets/javascripts/monitoring/components/monitoring_paths.vue @@ -0,0 +1,40 @@ +<script> + export default { + props: { + generatedLinePath: { + type: String, + required: true, + }, + generatedAreaPath: { + type: String, + required: true, + }, + lineColor: { + type: String, + required: true, + }, + areaColor: { + type: String, + required: true, + }, + }, + }; +</script> +<template> + <g> + <path + class="metric-area" + :d="generatedAreaPath" + :fill="areaColor" + transform="translate(-5, 20)"> + </path> + <path + class="metric-line" + :d="generatedLinePath" + :stroke="lineColor" + fill="none" + stroke-width="1" + transform="translate(-5, 20)"> + </path> + </g> +</template> diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js index 8e62fa63f13..345a0b37a76 100644 --- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js +++ b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js @@ -21,9 +21,9 @@ const mixins = { formatDeployments() { this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => { const time = new Date(deployment.created_at); - const xPos = Math.floor(this.xScale(time)); + const xPos = Math.floor(this.timeSeries[0].timeSeriesScaleX(time)); - time.setSeconds(this.data[0].time.getSeconds()); + time.setSeconds(this.timeSeries[0].values[0].time.getSeconds()); if (xPos >= 0) { deploymentDataArray.push({ diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js index 737c964f12e..0a4cdd88044 100644 --- a/app/assets/javascripts/monitoring/stores/monitoring_store.js +++ b/app/assets/javascripts/monitoring/stores/monitoring_store.js @@ -1,46 +1,52 @@ import _ from 'underscore'; -class MonitoringStore { +function sortMetrics(metrics) { + return _.chain(metrics).sortBy('weight').sortBy('title').value(); +} + +function normalizeMetrics(metrics) { + return metrics.map(metric => ({ + ...metric, + queries: metric.queries.map(query => ({ + ...query, + result: query.result.map(result => ({ + ...result, + values: result.values.map(([timestamp, value]) => ({ + time: new Date(timestamp * 1000), + value, + })), + })), + })), + })); +} + +function collate(array, rows = 2) { + const collatedArray = []; + let row = []; + array.forEach((value, index) => { + row.push(value); + if ((index + 1) % rows === 0) { + collatedArray.push(row); + row = []; + } + }); + if (row.length > 0) { + collatedArray.push(row); + } + return collatedArray; +} + +export default class MonitoringStore { constructor() { this.groups = []; this.deploymentData = []; } - // eslint-disable-next-line class-methods-use-this - createArrayRows(metrics = []) { - const currentMetrics = metrics; - const availableMetrics = []; - let metricsRow = []; - let index = 1; - Object.keys(currentMetrics).forEach((key) => { - const metricValues = currentMetrics[key].queries[0].result[0].values; - if (metricValues != null) { - const literalMetrics = metricValues.map(metric => ({ - time: new Date(metric[0] * 1000), - value: metric[1], - })); - currentMetrics[key].queries[0].result[0].values = literalMetrics; - metricsRow.push(currentMetrics[key]); - if (index % 2 === 0) { - availableMetrics.push(metricsRow); - metricsRow = []; - } - index = index += 1; - } - }); - if (metricsRow.length > 0) { - availableMetrics.push(metricsRow); - } - return availableMetrics; - } - storeMetrics(groups = []) { - this.groups = groups.map((group) => { - const currentGroup = group; - currentGroup.metrics = _.chain(group.metrics).sortBy('weight').sortBy('title').value(); - currentGroup.metrics = this.createArrayRows(currentGroup.metrics); - return currentGroup; - }); + this.groups = groups.map(group => ({ + ...group, + metrics: collate(normalizeMetrics(sortMetrics(group.metrics))), + })); } storeDeploymentData(deploymentData = []) { @@ -57,5 +63,3 @@ class MonitoringStore { return metricsCount; } } - -export default MonitoringStore; diff --git a/app/assets/javascripts/monitoring/utils/measurements.js b/app/assets/javascripts/monitoring/utils/measurements.js index 62cd19c86e1..ee3c45efacc 100644 --- a/app/assets/javascripts/monitoring/utils/measurements.js +++ b/app/assets/javascripts/monitoring/utils/measurements.js @@ -7,15 +7,15 @@ export default { left: 40, }, legends: { - width: 15, - height: 25, + width: 10, + height: 3, }, backgroundLegend: { width: 30, height: 50, }, axisLabelLineOffset: -20, - legendOffset: 35, + legendOffset: 33, }, large: { // This covers both md and lg screen sizes margin: { @@ -25,15 +25,15 @@ export default { left: 80, }, legends: { - width: 20, - height: 30, + width: 15, + height: 3, }, backgroundLegend: { width: 30, height: 150, }, axisLabelLineOffset: 20, - legendOffset: 38, + legendOffset: 36, }, xTicks: 8, yTicks: 3, diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js new file mode 100644 index 00000000000..05d551e917c --- /dev/null +++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js @@ -0,0 +1,80 @@ +import d3 from 'd3'; +import _ from 'underscore'; + +export default function createTimeSeries(seriesData, graphWidth, graphHeight, graphHeightOffset) { + const maxValues = seriesData.map((timeSeries, index) => { + const maxValue = d3.max(timeSeries.values.map(d => d.value)); + return { + maxValue, + index, + }; + }); + + const maxValueFromSeries = _.max(maxValues, val => val.maxValue); + + let timeSeriesNumber = 1; + let lineColor = '#1f78d1'; + let areaColor = '#8fbce8'; + return seriesData.map((timeSeries) => { + const timeSeriesScaleX = d3.time.scale() + .range([0, graphWidth - 70]); + + const timeSeriesScaleY = d3.scale.linear() + .range([graphHeight - graphHeightOffset, 0]); + + timeSeriesScaleX.domain(d3.extent(timeSeries.values, d => d.time)); + timeSeriesScaleY.domain([0, maxValueFromSeries.maxValue]); + + const lineFunction = d3.svg.line() + .x(d => timeSeriesScaleX(d.time)) + .y(d => timeSeriesScaleY(d.value)); + + const areaFunction = d3.svg.area() + .x(d => timeSeriesScaleX(d.time)) + .y0(graphHeight - graphHeightOffset) + .y1(d => timeSeriesScaleY(d.value)) + .interpolate('linear'); + + switch (timeSeriesNumber) { + case 1: + lineColor = '#1f78d1'; + areaColor = '#8fbce8'; + break; + case 2: + lineColor = '#fc9403'; + areaColor = '#feca81'; + break; + case 3: + lineColor = '#db3b21'; + areaColor = '#ed9d90'; + break; + case 4: + lineColor = '#1aaa55'; + areaColor = '#8dd5aa'; + break; + case 5: + lineColor = '#6666c4'; + areaColor = '#d1d1f0'; + break; + default: + lineColor = '#1f78d1'; + areaColor = '#8fbce8'; + break; + } + + if (timeSeriesNumber <= 5) { + timeSeriesNumber = timeSeriesNumber += 1; + } else { + timeSeriesNumber = 1; + } + + return { + linePath: lineFunction(timeSeries.values), + areaPath: areaFunction(timeSeries.values), + timeSeriesScaleX, + values: timeSeries.values, + lineColor, + areaColor, + }; + }); +} diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index b38a6abc8d1..a09270d6d24 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -464,7 +464,6 @@ export default class Notes { } renderDiscussionAvatar(diffAvatarContainer, noteEntity) { - var commentButton = diffAvatarContainer.find('.js-add-diff-note-button'); var avatarHolder = diffAvatarContainer.find('.diff-comment-avatar-holders'); if (!avatarHolder.length) { @@ -475,10 +474,6 @@ export default class Notes { gl.diffNotesCompileComponents(); } - - if (commentButton.length) { - commentButton.remove(); - } } /** @@ -767,6 +762,7 @@ export default class Notes { var $note, $notes; $note = $(el); $notes = $note.closest('.discussion-notes'); + const discussionId = $('.notes', $notes).data('discussion-id'); if (typeof gl.diffNotesCompileComponents !== 'undefined') { if (gl.diffNoteApps[noteElId]) { @@ -783,6 +779,8 @@ export default class Notes { // "Discussions" tab $notes.closest('.timeline-entry').remove(); + $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); + // The notes tr can contain multiple lists of notes, like on the parallel diff if (notesTr.find('.discussion-notes').length > 1) { $notes.remove(); diff --git a/app/assets/javascripts/notes/components/issue_comment_form.vue b/app/assets/javascripts/notes/components/issue_comment_form.vue new file mode 100644 index 00000000000..16f4e22aa9b --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_comment_form.vue @@ -0,0 +1,347 @@ +<script> + /* global Flash, Autosave */ + import { mapActions, mapGetters } from 'vuex'; + import _ from 'underscore'; + import '../../autosave'; + import TaskList from '../../task_list'; + import * as constants from '../constants'; + import eventHub from '../event_hub'; + import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue'; + import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue'; + import markdownField from '../../vue_shared/components/markdown/field.vue'; + import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + + export default { + name: 'issueCommentForm', + data() { + return { + note: '', + noteType: constants.COMMENT, + // Can't use mapGetters, + // this needs to be in the data object because it belongs to the state + issueState: this.$store.getters.getIssueData.state, + isSubmitting: false, + isSubmitButtonDisabled: true, + }; + }, + components: { + confidentialIssue, + issueNoteSignedOutWidget, + markdownField, + userAvatarLink, + }, + watch: { + note(newNote) { + this.setIsSubmitButtonDisabled(newNote, this.isSubmitting); + }, + isSubmitting(newValue) { + this.setIsSubmitButtonDisabled(this.note, newValue); + }, + }, + computed: { + ...mapGetters([ + 'getCurrentUserLastNote', + 'getUserData', + 'getIssueData', + 'getNotesData', + ]), + isLoggedIn() { + return this.getUserData.id; + }, + commentButtonTitle() { + return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; + }, + isIssueOpen() { + return this.issueState === constants.OPENED || this.issueState === constants.REOPENED; + }, + issueActionButtonTitle() { + if (this.note.length) { + const actionText = this.isIssueOpen ? 'close' : 'reopen'; + + return this.noteType === constants.COMMENT ? `Comment & ${actionText} issue` : `Start discussion & ${actionText} issue`; + } + + return this.isIssueOpen ? 'Close issue' : 'Reopen issue'; + }, + actionButtonClassNames() { + return { + 'btn-reopen': !this.isIssueOpen, + 'btn-close': this.isIssueOpen, + 'js-note-target-close': this.isIssueOpen, + 'js-note-target-reopen': !this.isIssueOpen, + }; + }, + markdownDocsPath() { + return this.getNotesData.markdownDocsPath; + }, + quickActionsDocsPath() { + return this.getNotesData.quickActionsDocsPath; + }, + markdownPreviewPath() { + return this.getIssueData.preview_note_path; + }, + author() { + return this.getUserData; + }, + canUpdateIssue() { + return this.getIssueData.current_user.can_update; + }, + endpoint() { + return this.getIssueData.create_note_path; + }, + isConfidentialIssue() { + return this.getIssueData.confidential; + }, + }, + methods: { + ...mapActions([ + 'saveNote', + 'removePlaceholderNotes', + ]), + setIsSubmitButtonDisabled(note, isSubmitting) { + if (!_.isEmpty(note) && !isSubmitting) { + this.isSubmitButtonDisabled = false; + } else { + this.isSubmitButtonDisabled = true; + } + }, + handleSave(withIssueAction) { + if (this.note.length) { + const noteData = { + endpoint: this.endpoint, + flashContainer: this.$el, + data: { + note: { + noteable_type: constants.NOTEABLE_TYPE, + noteable_id: this.getIssueData.id, + note: this.note, + }, + }, + }; + + if (this.noteType === constants.DISCUSSION) { + noteData.data.note.type = constants.DISCUSSION_NOTE; + } + this.isSubmitting = true; + this.note = ''; // Empty textarea while being requested. Repopulate in catch + + this.saveNote(noteData) + .then((res) => { + this.isSubmitting = false; + if (res.errors) { + if (res.errors.commands_only) { + this.discard(); + } else { + Flash( + 'Something went wrong while adding your comment. Please try again.', + 'alert', + $(this.$refs.commentForm), + ); + } + } else { + this.discard(); + } + + if (withIssueAction) { + this.toggleIssueState(); + } + }) + .catch(() => { + this.isSubmitting = false; + this.discard(false); + const msg = 'Your comment could not be submitted! Please check your network connection and try again.'; + Flash(msg, 'alert', $(this.$el)); + this.note = noteData.data.note.note; // Restore textarea content. + this.removePlaceholderNotes(); + }); + } else { + this.toggleIssueState(); + } + }, + toggleIssueState() { + this.issueState = this.isIssueOpen ? constants.CLOSED : constants.REOPENED; + + // This is out of scope for the Notes Vue component. + // It was the shortest path to update the issue state and relevant places. + const btnClass = this.isIssueOpen ? 'btn-reopen' : 'btn-close'; + $(`.js-btn-issue-action.${btnClass}:visible`).trigger('click'); + }, + discard(shouldClear = true) { + // `blur` is needed to clear slash commands autocomplete cache if event fired. + // `focus` is needed to remain cursor in the textarea. + this.$refs.textarea.blur(); + this.$refs.textarea.focus(); + + if (shouldClear) { + this.note = ''; + } + + // reset autostave + this.autosave.reset(); + }, + setNoteType(type) { + this.noteType = type; + }, + editCurrentUserLastNote() { + if (this.note === '') { + const lastNote = this.getCurrentUserLastNote; + + if (lastNote) { + eventHub.$emit('enterEditMode', { + noteId: lastNote.id, + }); + } + } + }, + initAutoSave() { + if (this.isLoggedIn) { + this.autosave = new Autosave($(this.$refs.textarea), ['Note', 'Issue', this.getIssueData.id], 'issue'); + } + }, + initTaskList() { + return new TaskList({ + dataType: 'note', + fieldName: 'note', + selector: '.notes', + }); + }, + }, + mounted() { + // jQuery is needed here because it is a custom event being dispatched with jQuery. + $(document).on('issuable:change', (e, isClosed) => { + this.issueState = isClosed ? constants.CLOSED : constants.REOPENED; + }); + + this.initAutoSave(); + this.initTaskList(); + }, + }; +</script> + +<template> + <div> + <issue-note-signed-out-widget v-if="!isLoggedIn" /> + <ul + v-else + class="notes notes-form timeline"> + <li class="timeline-entry"> + <div class="timeline-entry-inner"> + <div class="flash-container error-alert timeline-content"></div> + <div class="timeline-icon hidden-xs hidden-sm"> + <user-avatar-link + v-if="author" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content timeline-content-form"> + <form + ref="commentForm" + class="new-note js-quick-submit common-note-form gfm-form js-main-target-form"> + <confidentialIssue v-if="isConfidentialIssue" /> + <div class="error-alert"></div> + <markdown-field + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + :add-spacing-classes="false" + :is-confidential-issue="isConfidentialIssue"> + <textarea + id="note-body" + name="note[note]" + class="note-textarea js-vue-comment-form js-gfm-input js-autosize markdown-area js-vue-textarea" + data-supports-quick-actions="true" + aria-label="Description" + v-model="note" + ref="textarea" + slot="textarea" + placeholder="Write a comment or drag your files here..." + @keydown.up="editCurrentUserLastNote()" + @keydown.meta.enter="handleSave()"> + </textarea> + </markdown-field> + <div class="note-form-actions"> + <div class="pull-left btn-group append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"> + <button + @click.prevent="handleSave()" + :disabled="isSubmitButtonDisabled" + class="btn btn-create comment-btn js-comment-button js-comment-submit-button" + type="submit"> + {{commentButtonTitle}} + </button> + <button + :disabled="isSubmitButtonDisabled" + name="button" + type="button" + class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle" + data-toggle="dropdown" + aria-label="Open comment type dropdown"> + <i + aria-hidden="true" + class="fa fa-caret-down toggle-icon"> + </i> + </button> + + <ul class="note-type-dropdown dropdown-open-top dropdown-menu"> + <li :class="{ 'droplab-item-selected': noteType === 'comment' }"> + <button + type="button" + class="btn btn-transparent" + @click.prevent="setNoteType('comment')"> + <i + aria-hidden="true" + class="fa fa-check icon"> + </i> + <div class="description"> + <strong>Comment</strong> + <p> + Add a general comment to this issue. + </p> + </div> + </button> + </li> + <li class="divider droplab-item-ignore"></li> + <li :class="{ 'droplab-item-selected': noteType === 'discussion' }"> + <button + type="button" + class="btn btn-transparent" + @click.prevent="setNoteType('discussion')"> + <i + aria-hidden="true" + class="fa fa-check icon"> + </i> + <div class="description"> + <strong>Start discussion</strong> + <p> + Discuss a specific suggestion or question. + </p> + </div> + </button> + </li> + </ul> + </div> + <button + type="button" + @click="handleSave(true)" + v-if="canUpdateIssue" + :class="actionButtonClassNames" + class="btn btn-comment btn-comment-and-close"> + {{issueActionButtonTitle}} + </button> + <button + type="button" + v-if="note.length" + @click="discard" + class="btn btn-cancel js-note-discard"> + Discard draft + </button> + </div> + </form> + </div> + </div> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_discussion.vue b/app/assets/javascripts/notes/components/issue_discussion.vue new file mode 100644 index 00000000000..b131ef4b182 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_discussion.vue @@ -0,0 +1,232 @@ +<script> + /* global Flash */ + import { mapActions, mapGetters } from 'vuex'; + import { SYSTEM_NOTE } from '../constants'; + import issueNote from './issue_note.vue'; + import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + import issueNoteHeader from './issue_note_header.vue'; + import issueNoteActions from './issue_note_actions.vue'; + import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue'; + import issueNoteEditedText from './issue_note_edited_text.vue'; + import issueNoteForm from './issue_note_form.vue'; + import placeholderNote from './issue_placeholder_note.vue'; + import placeholderSystemNote from './issue_placeholder_system_note.vue'; + import autosave from '../mixins/autosave'; + + export default { + props: { + note: { + type: Object, + required: true, + }, + }, + data() { + return { + isReplying: false, + }; + }, + components: { + issueNote, + userAvatarLink, + issueNoteHeader, + issueNoteActions, + issueNoteSignedOutWidget, + issueNoteEditedText, + issueNoteForm, + placeholderNote, + placeholderSystemNote, + }, + mixins: [ + autosave, + ], + computed: { + ...mapGetters([ + 'getIssueData', + ]), + discussion() { + return this.note.notes[0]; + }, + author() { + return this.discussion.author; + }, + canReply() { + return this.getIssueData.current_user.can_create_note; + }, + newNotePath() { + return this.getIssueData.create_note_path; + }, + lastUpdatedBy() { + const { notes } = this.note; + + if (notes.length > 1) { + return notes[notes.length - 1].author; + } + + return null; + }, + lastUpdatedAt() { + const { notes } = this.note; + + if (notes.length > 1) { + return notes[notes.length - 1].created_at; + } + + return null; + }, + }, + methods: { + ...mapActions([ + 'saveNote', + 'toggleDiscussion', + 'removePlaceholderNotes', + ]), + componentName(note) { + if (note.isPlaceholderNote) { + if (note.placeholderType === SYSTEM_NOTE) { + return placeholderSystemNote; + } + return placeholderNote; + } + + return issueNote; + }, + componentData(note) { + return note.isPlaceholderNote ? note.notes[0] : note; + }, + toggleDiscussionHandler() { + this.toggleDiscussion({ discussionId: this.note.id }); + }, + showReplyForm() { + this.isReplying = true; + }, + cancelReplyForm(shouldConfirm) { + if (shouldConfirm && this.$refs.noteForm.isDirty) { + // eslint-disable-next-line no-alert + if (!confirm('Are you sure you want to cancel creating this comment?')) { + return; + } + } + + this.resetAutoSave(); + this.isReplying = false; + }, + saveReply(noteText, form, callback) { + const replyData = { + endpoint: this.newNotePath, + flashContainer: this.$el, + data: { + in_reply_to_discussion_id: this.note.reply_id, + target_type: 'issue', + target_id: this.discussion.noteable_id, + note: { note: noteText }, + }, + }; + this.isReplying = false; + + this.saveNote(replyData) + .then(() => { + this.resetAutoSave(); + callback(); + }) + .catch((err) => { + this.removePlaceholderNotes(); + this.isReplying = true; + this.$nextTick(() => { + const msg = 'Your comment could not be submitted! Please check your network connection and try again.'; + Flash(msg, 'alert', $(this.$el)); + this.$refs.noteForm.note = noteText; + callback(err); + }); + }); + }, + }, + mounted() { + if (this.isReplying) { + this.initAutoSave(); + } + }, + updated() { + if (this.isReplying) { + if (!this.autosave) { + this.initAutoSave(); + } else { + this.setAutoSave(); + } + } + }, + }; +</script> + +<template> + <li class="note note-discussion timeline-entry"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + <user-avatar-link + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content"> + <div class="discussion"> + <div class="discussion-header"> + <issue-note-header + :author="author" + :created-at="discussion.created_at" + :note-id="discussion.id" + :include-toggle="true" + @toggleHandler="toggleDiscussionHandler" + action-text="started a discussion" + class="discussion" + /> + <issue-note-edited-text + v-if="lastUpdatedAt" + :edited-at="lastUpdatedAt" + :edited-by="lastUpdatedBy" + action-text="Last updated" + class-name="discussion-headline-light js-discussion-headline" + /> + </div> + </div> + <div + v-if="note.expanded" + class="discussion-body"> + <div class="panel panel-default"> + <div class="discussion-notes"> + <ul class="notes"> + <component + v-for="note in note.notes" + :is="componentName(note)" + :note="componentData(note)" + :key="note.id" + /> + </ul> + <div + :class="{ 'is-replying': isReplying }" + class="discussion-reply-holder"> + <button + v-if="canReply && !isReplying" + @click="showReplyForm" + type="button" + class="js-vue-discussion-reply btn btn-text-field" + title="Add a reply">Reply...</button> + <issue-note-form + v-if="isReplying" + save-button-title="Comment" + :discussion="note" + :is-editing="false" + @handleFormUpdate="saveReply" + @cancelFormEdition="cancelReplyForm" + ref="noteForm" + /> + <issue-note-signed-out-widget v-if="!canReply" /> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note.vue b/app/assets/javascripts/notes/components/issue_note.vue new file mode 100644 index 00000000000..3483f6c7538 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note.vue @@ -0,0 +1,186 @@ +<script> + /* global Flash */ + + import { mapGetters, mapActions } from 'vuex'; + import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + import issueNoteHeader from './issue_note_header.vue'; + import issueNoteActions from './issue_note_actions.vue'; + import issueNoteBody from './issue_note_body.vue'; + import eventHub from '../event_hub'; + + export default { + props: { + note: { + type: Object, + required: true, + }, + }, + data() { + return { + isEditing: false, + isDeleting: false, + isRequesting: false, + }; + }, + components: { + userAvatarLink, + issueNoteHeader, + issueNoteActions, + issueNoteBody, + }, + computed: { + ...mapGetters([ + 'targetNoteHash', + 'getUserData', + ]), + author() { + return this.note.author; + }, + classNameBindings() { + return { + 'is-editing': this.isEditing && !this.isRequesting, + 'is-requesting being-posted': this.isRequesting, + 'disabled-content': this.isDeleting, + target: this.targetNoteHash === this.noteAnchorId, + }; + }, + canReportAsAbuse() { + return this.note.report_abuse_path && this.author.id !== this.getUserData.id; + }, + noteAnchorId() { + return `note_${this.note.id}`; + }, + }, + methods: { + ...mapActions([ + 'deleteNote', + 'updateNote', + 'scrollToNoteIfNeeded', + ]), + editHandler() { + this.isEditing = true; + }, + deleteHandler() { + // eslint-disable-next-line no-alert + if (confirm('Are you sure you want to delete this list?')) { + this.isDeleting = true; + + this.deleteNote(this.note) + .then(() => { + this.isDeleting = false; + }) + .catch(() => { + Flash('Something went wrong while deleting your note. Please try again.'); + this.isDeleting = false; + }); + } + }, + formUpdateHandler(noteText, parentElement, callback) { + const data = { + endpoint: this.note.path, + note: { + target_type: 'issue', + target_id: this.note.noteable_id, + note: { note: noteText }, + }, + }; + this.isRequesting = true; + this.oldContent = this.note.note_html; + this.note.note_html = noteText; + + this.updateNote(data) + .then(() => { + this.isEditing = false; + this.isRequesting = false; + $(this.$refs.noteBody.$el).renderGFM(); + this.$refs.noteBody.resetAutoSave(); + callback(); + }) + .catch(() => { + this.isRequesting = false; + this.isEditing = true; + this.$nextTick(() => { + const msg = 'Something went wrong while editing your comment. Please try again.'; + Flash(msg, 'alert', $(this.$el)); + this.recoverNoteContent(noteText); + callback(); + }); + }); + }, + formCancelHandler(shouldConfirm, isDirty) { + if (shouldConfirm && isDirty) { + // eslint-disable-next-line no-alert + if (!confirm('Are you sure you want to cancel editing this comment?')) return; + } + this.$refs.noteBody.resetAutoSave(); + if (this.oldContent) { + this.note.note_html = this.oldContent; + this.oldContent = null; + } + this.isEditing = false; + }, + recoverNoteContent(noteText) { + // we need to do this to prevent noteForm inconsistent content warning + // this is something we intentionally do so we need to recover the content + this.note.note = noteText; + this.$refs.noteBody.$refs.noteForm.note = noteText; // TODO: This could be better + }, + }, + created() { + eventHub.$on('enterEditMode', ({ noteId }) => { + if (noteId === this.note.id) { + this.isEditing = true; + this.scrollToNoteIfNeeded($(this.$el)); + } + }); + }, + }; +</script> + +<template> + <li + class="note timeline-entry" + :id="noteAnchorId" + :class="classNameBindings" + :data-award-url="note.toggle_award_path"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + <user-avatar-link + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content"> + <div class="note-header"> + <issue-note-header + :author="author" + :created-at="note.created_at" + :note-id="note.id" + action-text="commented" + /> + <issue-note-actions + :author-id="author.id" + :note-id="note.id" + :access-level="note.human_access" + :can-edit="note.current_user.can_edit" + :can-delete="note.current_user.can_edit" + :can-report-as-abuse="canReportAsAbuse" + :report-abuse-path="note.report_abuse_path" + @handleEdit="editHandler" + @handleDelete="deleteHandler" + /> + </div> + <issue-note-body + :note="note" + :can-edit="note.current_user.can_edit" + :is-editing="isEditing" + @handleFormUpdate="formUpdateHandler" + @cancelFormEdition="formCancelHandler" + ref="noteBody" + /> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_actions.vue b/app/assets/javascripts/notes/components/issue_note_actions.vue new file mode 100644 index 00000000000..60c172321d1 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_actions.vue @@ -0,0 +1,167 @@ +<script> + import { mapGetters } from 'vuex'; + import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; + import emojiSmile from 'icons/_emoji_smile.svg'; + import emojiSmiley from 'icons/_emoji_smiley.svg'; + import editSvg from 'icons/_icon_pencil.svg'; + import ellipsisSvg from 'icons/_ellipsis_v.svg'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + import tooltip from '../../vue_shared/directives/tooltip'; + + export default { + name: 'issueNoteActions', + props: { + authorId: { + type: Number, + required: true, + }, + noteId: { + type: Number, + required: true, + }, + accessLevel: { + type: String, + required: false, + default: '', + }, + reportAbusePath: { + type: String, + required: true, + }, + canEdit: { + type: Boolean, + required: true, + }, + canDelete: { + type: Boolean, + required: true, + }, + canReportAsAbuse: { + type: Boolean, + required: true, + }, + }, + directives: { + tooltip, + }, + components: { + loadingIcon, + }, + computed: { + ...mapGetters([ + 'getUserDataByProp', + ]), + shouldShowActionsDropdown() { + return this.currentUserId && (this.canEdit || this.canReportAsAbuse); + }, + canAddAwardEmoji() { + return this.currentUserId; + }, + isAuthoredByCurrentUser() { + return this.authorId === this.currentUserId; + }, + currentUserId() { + return this.getUserDataByProp('id'); + }, + }, + methods: { + onEdit() { + this.$emit('handleEdit'); + }, + onDelete() { + this.$emit('handleDelete'); + }, + }, + created() { + this.emojiSmiling = emojiSmiling; + this.emojiSmile = emojiSmile; + this.emojiSmiley = emojiSmiley; + this.editSvg = editSvg; + this.ellipsisSvg = ellipsisSvg; + }, + }; +</script> + +<template> + <div class="note-actions"> + <span + v-if="accessLevel" + class="note-role">{{accessLevel}}</span> + <div + v-if="canAddAwardEmoji" + class="note-actions-item"> + <a + v-tooltip + :class="{ 'js-user-authored': isAuthoredByCurrentUser }" + class="note-action-button note-emoji-button js-add-award js-note-emoji" + data-position="right" + data-placement="bottom" + data-container="body" + href="#" + title="Add reaction"> + <loading-icon :inline="true" /> + <span + v-html="emojiSmiling" + class="link-highlight award-control-icon-neutral"> + </span> + <span + v-html="emojiSmiley" + class="link-highlight award-control-icon-positive"> + </span> + <span + v-html="emojiSmile" + class="link-highlight award-control-icon-super-positive"> + </span> + </a> + </div> + <div + v-if="canEdit" + class="note-actions-item"> + <button + @click="onEdit" + v-tooltip + type="button" + title="Edit comment" + class="note-action-button js-note-edit btn btn-transparent" + data-container="body" + data-placement="bottom"> + <span + v-html="editSvg" + class="link-highlight"></span> + </button> + </div> + <div + v-if="shouldShowActionsDropdown" + class="dropdown more-actions note-actions-item"> + <button + v-tooltip + type="button" + title="More actions" + class="note-action-button more-actions-toggle btn btn-transparent" + data-toggle="dropdown" + data-container="body" + data-placement="bottom"> + <span + class="icon" + v-html="ellipsisSvg"></span> + </button> + <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> + <li v-if="canReportAsAbuse"> + <a :href="reportAbusePath"> + Report as abuse + </a> + </li> + <li v-if="canEdit"> + <button + @click.prevent="onDelete" + class="btn btn-transparent js-note-delete js-note-delete" + type="button"> + <span class="text-danger"> + Delete comment + </span> + </button> + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_attachment.vue b/app/assets/javascripts/notes/components/issue_note_attachment.vue new file mode 100644 index 00000000000..7134a3eb47e --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_attachment.vue @@ -0,0 +1,37 @@ +<script> + export default { + name: 'issueNoteAttachment', + props: { + attachment: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <div class="note-attachment"> + <a + v-if="attachment.image" + :href="attachment.url" + target="_blank" + rel="noopener noreferrer"> + <img + :src="attachment.url" + class="note-image-attach" /> + </a> + <div class="attachment"> + <a + v-if="attachment.url" + :href="attachment.url" + target="_blank" + rel="noopener noreferrer"> + <i + class="fa fa-paperclip" + aria-hidden="true"></i> + {{attachment.filename}} + </a> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_awards_list.vue b/app/assets/javascripts/notes/components/issue_note_awards_list.vue new file mode 100644 index 00000000000..d42e61e3899 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_awards_list.vue @@ -0,0 +1,228 @@ +<script> + /* global Flash */ + + import { mapActions, mapGetters } from 'vuex'; + import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; + import emojiSmile from 'icons/_emoji_smile.svg'; + import emojiSmiley from 'icons/_emoji_smiley.svg'; + import { glEmojiTag } from '../../emoji'; + import tooltip from '../../vue_shared/directives/tooltip'; + + export default { + props: { + awards: { + type: Array, + required: true, + }, + toggleAwardPath: { + type: String, + required: true, + }, + noteAuthorId: { + type: Number, + required: true, + }, + noteId: { + type: Number, + required: true, + }, + }, + directives: { + tooltip, + }, + computed: { + ...mapGetters([ + 'getUserData', + ]), + // `this.awards` is an array with emojis but they are not grouped by emoji name. See below. + // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ] + // This method will group emojis by their name as an Object. See below. + // { + // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ], + // bar: [ { name: bar, user: user1 } ] + // } + // We need to do this otherwise we will render the same emoji over and over again. + groupedAwards() { + const awards = this.awards.reduce((acc, award) => { + if (Object.prototype.hasOwnProperty.call(acc, award.name)) { + acc[award.name].push(award); + } else { + Object.assign(acc, { [award.name]: [award] }); + } + + return acc; + }, {}); + + const orderedAwards = {}; + const { thumbsdown, thumbsup } = awards; + // Always show thumbsup and thumbsdown first + if (thumbsup) { + orderedAwards.thumbsup = thumbsup; + delete awards.thumbsup; + } + if (thumbsdown) { + orderedAwards.thumbsdown = thumbsdown; + delete awards.thumbsdown; + } + + return Object.assign({}, orderedAwards, awards); + }, + isAuthoredByMe() { + return this.noteAuthorId === this.getUserData.id; + }, + isLoggedIn() { + return this.getUserData.id; + }, + }, + methods: { + ...mapActions([ + 'toggleAwardRequest', + ]), + getAwardHTML(name) { + return glEmojiTag(name); + }, + getAwardClassBindings(awardList, awardName) { + return { + active: this.hasReactionByCurrentUser(awardList), + disabled: !this.canInteractWithEmoji(awardList, awardName), + }; + }, + canInteractWithEmoji(awardList, awardName) { + let isAllowed = true; + const restrictedEmojis = ['thumbsup', 'thumbsdown']; + + // Users can not add :+1: and :-1: to their own notes + if (this.getUserData.id === this.noteAuthorId && restrictedEmojis.indexOf(awardName) > -1) { + isAllowed = false; + } + + return this.getUserData.id && isAllowed; + }, + hasReactionByCurrentUser(awardList) { + return awardList.filter(award => award.user.id === this.getUserData.id).length; + }, + awardTitle(awardsList) { + const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList); + const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; + let awardList = awardsList; + + // Filter myself from list if I am awarded. + if (hasReactionByCurrentUser) { + awardList = awardList.filter(award => award.user.id !== this.getUserData.id); + } + + // Get only 9-10 usernames to show in tooltip text. + const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name); + + // Get the remaining list to use in `and x more` text. + const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length); + + // Add myself to the begining of the list so title will start with You. + if (hasReactionByCurrentUser) { + namesToShow.unshift('You'); + } + + let title = ''; + + // We have 10+ awarded user, join them with comma and add `and x more`. + if (remainingAwardList.length) { + title = `${namesToShow.join(', ')}, and ${remainingAwardList.length} more.`; + } else if (namesToShow.length > 1) { + // Join all names with comma but not the last one, it will be added with and text. + title = namesToShow.slice(0, namesToShow.length - 1).join(', '); + // If we have more than 2 users we need an extra comma before and text. + title += namesToShow.length > 2 ? ',' : ''; + title += ` and ${namesToShow.slice(-1)}`; // Append and text + } else { // We have only 2 users so join them with and. + title = namesToShow.join(' and '); + } + + return title; + }, + handleAward(awardName) { + if (!this.isLoggedIn) { + return; + } + + let parsedName; + + // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string + switch (awardName) { + case '100': + parsedName = 100; + break; + case '1234': + parsedName = 1234; + break; + default: + parsedName = awardName; + break; + } + + const data = { + endpoint: this.toggleAwardPath, + noteId: this.noteId, + awardName: parsedName, + }; + + this.toggleAwardRequest(data) + .catch(() => Flash('Something went wrong on our end.')); + }, + }, + created() { + this.emojiSmiling = emojiSmiling; + this.emojiSmile = emojiSmile; + this.emojiSmiley = emojiSmiley; + }, + }; +</script> + +<template> + <div class="note-awards"> + <div class="awards js-awards-block"> + <button + v-tooltip + v-for="(awardList, awardName, index) in groupedAwards" + :key="index" + :class="getAwardClassBindings(awardList, awardName)" + :title="awardTitle(awardList)" + @click="handleAward(awardName)" + class="btn award-control" + data-placement="bottom" + type="button"> + <span v-html="getAwardHTML(awardName)"></span> + <span class="award-control-text js-counter"> + {{awardList.length}} + </span> + </button> + <div + v-if="isLoggedIn" + class="award-menu-holder"> + <button + v-tooltip + :class="{ 'js-user-authored': isAuthoredByMe }" + class="award-control btn js-add-award" + title="Add reaction" + aria-label="Add reaction" + data-placement="bottom" + type="button"> + <span + v-html="emojiSmiling" + class="award-control-icon award-control-icon-neutral"> + </span> + <span + v-html="emojiSmiley" + class="award-control-icon award-control-icon-positive"> + </span> + <span + v-html="emojiSmile" + class="award-control-icon award-control-icon-super-positive"> + </span> + <i + aria-hidden="true" + class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i> + </button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_body.vue b/app/assets/javascripts/notes/components/issue_note_body.vue new file mode 100644 index 00000000000..5f9003bfd87 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_body.vue @@ -0,0 +1,122 @@ +<script> + import issueNoteEditedText from './issue_note_edited_text.vue'; + import issueNoteAwardsList from './issue_note_awards_list.vue'; + import issueNoteAttachment from './issue_note_attachment.vue'; + import issueNoteForm from './issue_note_form.vue'; + import TaskList from '../../task_list'; + import autosave from '../mixins/autosave'; + + export default { + props: { + note: { + type: Object, + required: true, + }, + canEdit: { + type: Boolean, + required: true, + }, + isEditing: { + type: Boolean, + required: false, + default: false, + }, + }, + mixins: [ + autosave, + ], + components: { + issueNoteEditedText, + issueNoteAwardsList, + issueNoteAttachment, + issueNoteForm, + }, + computed: { + noteBody() { + return this.note.note; + }, + }, + methods: { + renderGFM() { + $(this.$refs['note-body']).renderGFM(); + }, + initTaskList() { + if (this.canEdit) { + this.taskList = new TaskList({ + dataType: 'note', + fieldName: 'note', + selector: '.notes', + }); + } + }, + handleFormUpdate(note, parentElement, callback) { + this.$emit('handleFormUpdate', note, parentElement, callback); + }, + formCancelHandler(shouldConfirm, isDirty) { + this.$emit('cancelFormEdition', shouldConfirm, isDirty); + }, + }, + mounted() { + this.renderGFM(); + this.initTaskList(); + + if (this.isEditing) { + this.initAutoSave(); + } + }, + updated() { + this.initTaskList(); + this.renderGFM(); + + if (this.isEditing) { + if (!this.autosave) { + this.initAutoSave(); + } else { + this.setAutoSave(); + } + } + }, + }; +</script> + +<template> + <div + :class="{ 'js-task-list-container': canEdit }" + ref="note-body" + class="note-body"> + <div + v-html="note.note_html" + class="note-text md"></div> + <issue-note-form + v-if="isEditing" + ref="noteForm" + @handleFormUpdate="handleFormUpdate" + @cancelFormEdition="formCancelHandler" + :is-editing="isEditing" + :note-body="noteBody" + :note-id="note.id" + /> + <textarea + v-if="canEdit" + v-model="note.note" + :data-update-url="note.path" + class="hidden js-task-list-field"></textarea> + <issue-note-edited-text + v-if="note.last_edited_at" + :edited-at="note.last_edited_at" + :edited-by="note.last_edited_by" + action-text="Edited" + /> + <issue-note-awards-list + v-if="note.award_emoji.length" + :note-id="note.id" + :note-author-id="note.author.id" + :awards="note.award_emoji" + :toggle-award-path="note.toggle_award_path" + /> + <issue-note-attachment + v-if="note.attachment" + :attachment="note.attachment" + /> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_edited_text.vue b/app/assets/javascripts/notes/components/issue_note_edited_text.vue new file mode 100644 index 00000000000..49e09f0ecc5 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_edited_text.vue @@ -0,0 +1,47 @@ +<script> + import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; + + export default { + name: 'editedNoteText', + props: { + actionText: { + type: String, + required: true, + }, + editedAt: { + type: String, + required: true, + }, + editedBy: { + type: Object, + required: false, + }, + className: { + type: String, + required: false, + default: 'edited-text', + }, + }, + components: { + timeAgoTooltip, + }, + }; +</script> + +<template> + <div :class="className"> + {{actionText}} + <time-ago-tooltip + :time="editedAt" + tooltip-placement="bottom" + /> + <template v-if="editedBy"> + by + <a + :href="editedBy.path" + class="js-vue-author author_link"> + {{editedBy.name}} + </a> + </template> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_form.vue b/app/assets/javascripts/notes/components/issue_note_form.vue new file mode 100644 index 00000000000..626c0f2ce18 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_form.vue @@ -0,0 +1,166 @@ +<script> + import { mapGetters } from 'vuex'; + import eventHub from '../event_hub'; + import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue'; + import markdownField from '../../vue_shared/components/markdown/field.vue'; + + export default { + name: 'issueNoteForm', + props: { + noteBody: { + type: String, + required: false, + default: '', + }, + noteId: { + type: Number, + required: false, + }, + saveButtonTitle: { + type: String, + required: false, + default: 'Save comment', + }, + discussion: { + type: Object, + required: false, + default: () => ({}), + }, + isEditing: { + type: Boolean, + required: true, + }, + }, + data() { + return { + note: this.noteBody, + conflictWhileEditing: false, + isSubmitting: false, + }; + }, + components: { + confidentialIssue, + markdownField, + }, + computed: { + ...mapGetters([ + 'getDiscussionLastNote', + 'getIssueDataByProp', + 'getNotesDataByProp', + 'getUserDataByProp', + ]), + noteHash() { + return `#note_${this.noteId}`; + }, + markdownPreviewPath() { + return this.getIssueDataByProp('preview_note_path'); + }, + markdownDocsPath() { + return this.getNotesDataByProp('markdownDocsPath'); + }, + quickActionsDocsPath() { + return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined; + }, + currentUserId() { + return this.getUserDataByProp('id'); + }, + isDisabled() { + return !this.note.length || this.isSubmitting; + }, + isConfidentialIssue() { + return this.getIssueDataByProp('confidential'); + }, + }, + methods: { + handleUpdate() { + this.isSubmitting = true; + + this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => { + this.isSubmitting = false; + }); + }, + editMyLastNote() { + if (this.note === '') { + const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion); + + if (lastNoteInDiscussion) { + eventHub.$emit('enterEditMode', { + noteId: lastNoteInDiscussion.id, + }); + } + } + }, + cancelHandler(shouldConfirm = false) { + // Sends information about confirm message and if the textarea has changed + this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note); + }, + }, + mounted() { + this.$refs.textarea.focus(); + }, + watch: { + noteBody() { + if (this.note === this.noteBody) { + this.note = this.noteBody; + } else { + this.conflictWhileEditing = true; + } + }, + }, + }; +</script> + +<template> + <div ref="editNoteForm" class="note-edit-form current-note-edit-form"> + <div + v-if="conflictWhileEditing" + class="js-conflict-edit-warning alert alert-danger"> + This comment has changed since you started editing, please review the + <a + :href="noteHash" + target="_blank" + rel="noopener noreferrer">updated comment</a> + to ensure information is not lost. + </div> + <div class="flash-container timeline-content"></div> + <form + class="edit-note common-note-form js-quick-submit gfm-form"> + <confidentialIssue v-if="isConfidentialIssue" /> + <markdown-field + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + :add-spacing-classes="false"> + <textarea + id="note_note" + name="note[note]" + class="note-textarea js-gfm-input js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" + :data-supports-quick-actions="!isEditing" + aria-label="Description" + v-model="note" + ref="textarea" + slot="textarea" + placeholder="Write a comment or drag your files here..." + @keydown.meta.enter="handleUpdate()" + @keydown.up="editMyLastNote()" + @keydown.esc="cancelHandler(true)"> + </textarea> + </markdown-field> + <div class="note-form-actions clearfix"> + <button + type="button" + @click="handleUpdate()" + :disabled="isDisabled" + class="js-vue-issue-save btn btn-save"> + {{saveButtonTitle}} + </button> + <button + @click="cancelHandler()" + class="btn btn-cancel note-edit-cancel" + type="button"> + Cancel + </button> + </div> + </form> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_header.vue b/app/assets/javascripts/notes/components/issue_note_header.vue new file mode 100644 index 00000000000..63aa3d777d0 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_header.vue @@ -0,0 +1,118 @@ +<script> + import { mapActions } from 'vuex'; + import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; + + export default { + props: { + author: { + type: Object, + required: true, + }, + createdAt: { + type: String, + required: true, + }, + actionText: { + type: String, + required: false, + default: '', + }, + actionTextHtml: { + type: String, + required: false, + default: '', + }, + noteId: { + type: Number, + required: true, + }, + includeToggle: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isExpanded: true, + }; + }, + components: { + timeAgoTooltip, + }, + computed: { + toggleChevronClass() { + return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down'; + }, + noteTimestampLink() { + return `#note_${this.noteId}`; + }, + }, + methods: { + ...mapActions([ + 'setTargetNoteHash', + ]), + handleToggle() { + this.isExpanded = !this.isExpanded; + this.$emit('toggleHandler'); + }, + updateTargetNoteHash() { + this.setTargetNoteHash(this.noteTimestampLink); + }, + }, + }; +</script> + +<template> + <div class="note-header-info"> + <a :href="author.path"> + <span class="note-header-author-name"> + {{author.name}} + </span> + <span class="note-headline-light"> + @{{author.username}} + </span> + </a> + <span class="note-headline-light"> + <span class="note-headline-meta"> + <template v-if="actionText"> + {{actionText}} + </template> + <span + v-if="actionTextHtml" + v-html="actionTextHtml" + class="system-note-message"> + </span> + <a + :href="noteTimestampLink" + @click="updateTargetNoteHash" + class="note-timestamp"> + <time-ago-tooltip + :time="createdAt" + tooltip-placement="bottom" + /> + </a> + <i + class="fa fa-spinner fa-spin editing-spinner" + aria-label="Comment is being updated" + aria-hidden="true"> + </i> + </span> + </span> + <div + v-if="includeToggle" + class="discussion-actions"> + <button + @click="handleToggle" + class="note-action-button discussion-toggle-button js-vue-toggle-button" + type="button"> + <i + :class="toggleChevronClass" + class="fa" + aria-hidden="true"> + </i> + Toggle discussion + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_icons.js b/app/assets/javascripts/notes/components/issue_note_icons.js new file mode 100644 index 00000000000..d8e3cb4bc01 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_icons.js @@ -0,0 +1,37 @@ +import iconArrowCircle from 'icons/_icon_arrow_circle_o_right.svg'; +import iconCheck from 'icons/_icon_check_square_o.svg'; +import iconClock from 'icons/_icon_clock_o.svg'; +import iconCodeFork from 'icons/_icon_code_fork.svg'; +import iconComment from 'icons/_icon_comment_o.svg'; +import iconCommit from 'icons/_icon_commit.svg'; +import iconEdit from 'icons/_icon_edit.svg'; +import iconEye from 'icons/_icon_eye.svg'; +import iconEyeSlash from 'icons/_icon_eye_slash.svg'; +import iconMerge from 'icons/_icon_merge.svg'; +import iconMerged from 'icons/_icon_merged.svg'; +import iconRandom from 'icons/_icon_random.svg'; +import iconClosed from 'icons/_icon_status_closed.svg'; +import iconStatusOpen from 'icons/_icon_status_open.svg'; +import iconStopwatch from 'icons/_icon_stopwatch.svg'; +import iconTags from 'icons/_icon_tags.svg'; +import iconUser from 'icons/_icon_user.svg'; + +export default { + icon_arrow_circle_o_right: iconArrowCircle, + icon_check_square_o: iconCheck, + icon_clock_o: iconClock, + icon_code_fork: iconCodeFork, + icon_comment_o: iconComment, + icon_commit: iconCommit, + icon_edit: iconEdit, + icon_eye: iconEye, + icon_eye_slash: iconEyeSlash, + icon_merge: iconMerge, + icon_merged: iconMerged, + icon_random: iconRandom, + icon_status_closed: iconClosed, + icon_status_open: iconStatusOpen, + icon_stopwatch: iconStopwatch, + icon_tags: iconTags, + icon_user: iconUser, +}; diff --git a/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue b/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue new file mode 100644 index 00000000000..77af3594c1c --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue @@ -0,0 +1,28 @@ +<script> + import { mapGetters } from 'vuex'; + + export default { + name: 'singInLinksNotes', + computed: { + ...mapGetters([ + 'getNotesDataByProp', + ]), + registerLink() { + return this.getNotesDataByProp('registerPath'); + }, + signInLink() { + return this.getNotesDataByProp('newSessionPath'); + }, + }, + }; +</script> + +<template> + <div class="disabled-comment text-center"> + Please + <a :href="registerLink">register</a> + or + <a :href="signInLink">sign in</a> + to reply + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_notes_app.vue b/app/assets/javascripts/notes/components/issue_notes_app.vue new file mode 100644 index 00000000000..b6fc5e5036f --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_notes_app.vue @@ -0,0 +1,151 @@ +<script> + /* global Flash */ + import { mapGetters, mapActions } from 'vuex'; + import store from '../stores/'; + import * as constants from '../constants'; + import issueNote from './issue_note.vue'; + import issueDiscussion from './issue_discussion.vue'; + import issueSystemNote from './issue_system_note.vue'; + import issueCommentForm from './issue_comment_form.vue'; + import placeholderNote from './issue_placeholder_note.vue'; + import placeholderSystemNote from './issue_placeholder_system_note.vue'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + + export default { + name: 'issueNotesApp', + props: { + issueData: { + type: Object, + required: true, + }, + notesData: { + type: Object, + required: true, + }, + userData: { + type: Object, + required: false, + default: {}, + }, + }, + store, + data() { + return { + isLoading: true, + }; + }, + components: { + issueNote, + issueDiscussion, + issueSystemNote, + issueCommentForm, + loadingIcon, + placeholderNote, + placeholderSystemNote, + }, + computed: { + ...mapGetters([ + 'notes', + 'getNotesDataByProp', + ]), + }, + methods: { + ...mapActions({ + actionFetchNotes: 'fetchNotes', + poll: 'poll', + actionToggleAward: 'toggleAward', + scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', + setNotesData: 'setNotesData', + setIssueData: 'setIssueData', + setUserData: 'setUserData', + setLastFetchedAt: 'setLastFetchedAt', + setTargetNoteHash: 'setTargetNoteHash', + }), + getComponentName(note) { + if (note.isPlaceholderNote) { + if (note.placeholderType === constants.SYSTEM_NOTE) { + return placeholderSystemNote; + } + return placeholderNote; + } else if (note.individual_note) { + return note.notes[0].system ? issueSystemNote : issueNote; + } + + return issueDiscussion; + }, + getComponentData(note) { + return note.individual_note ? note.notes[0] : note; + }, + fetchNotes() { + return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath')) + .then(() => this.initPolling()) + .then(() => { + this.isLoading = false; + }) + .then(() => this.$nextTick()) + .then(() => this.checkLocationHash()) + .catch(() => { + this.isLoading = false; + Flash('Something went wrong while fetching issue comments. Please try again.'); + }); + }, + initPolling() { + this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); + + this.poll(); + }, + checkLocationHash() { + const hash = gl.utils.getLocationHash(); + const element = document.getElementById(hash); + + if (hash && element) { + this.setTargetNoteHash(hash); + this.scrollToNoteIfNeeded($(element)); + } + }, + }, + created() { + this.setNotesData(this.notesData); + this.setIssueData(this.issueData); + this.setUserData(this.userData); + }, + mounted() { + this.fetchNotes(); + + const parentElement = this.$el.parentElement; + + if (parentElement && + parentElement.classList.contains('js-vue-notes-event')) { + parentElement.addEventListener('toggleAward', (event) => { + const { awardName, noteId } = event.detail; + this.actionToggleAward({ awardName, noteId }); + }); + } + }, + }; +</script> + +<template> + <div id="notes"> + <div + v-if="isLoading" + class="js-loading loading"> + <loading-icon /> + </div> + + <ul + v-if="!isLoading" + id="notes-list" + class="notes main-notes-list timeline"> + + <component + v-for="note in notes" + :is="getComponentName(note)" + :note="getComponentData(note)" + :key="note.id" + /> + </ul> + + <issue-comment-form /> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_placeholder_note.vue b/app/assets/javascripts/notes/components/issue_placeholder_note.vue new file mode 100644 index 00000000000..6921d91372f --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_placeholder_note.vue @@ -0,0 +1,53 @@ +<script> + import { mapGetters } from 'vuex'; + import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + + export default { + name: 'issuePlaceholderNote', + props: { + note: { + type: Object, + required: true, + }, + }, + components: { + userAvatarLink, + }, + computed: { + ...mapGetters([ + 'getUserData', + ]), + }, + }; +</script> + +<template> + <li class="note being-posted fade-in-half timeline-entry"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + <user-avatar-link + :link-href="getUserData.path" + :img-src="getUserData.avatar_url" + :img-size="40" + /> + </div> + <div + :class="{ discussion: !note.individual_note }" + class="timeline-content"> + <div class="note-header"> + <div class="note-header-info"> + <a :href="getUserData.path"> + <span class="hidden-xs">{{getUserData.name}}</span> + <span class="note-headline-light">@{{getUserData.username}}</span> + </a> + </div> + </div> + <div class="note-body"> + <div class="note-text"> + <p>{{note.body}}</p> + </div> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue b/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue new file mode 100644 index 00000000000..80a8ef56a83 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue @@ -0,0 +1,21 @@ +<script> + export default { + name: 'placeholderSystemNote', + props: { + note: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <li class="note system-note timeline-entry being-posted fade-in-half"> + <div class="timeline-entry-inner"> + <div class="timeline-content"> + <em>{{note.body}}</em> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/issue_system_note.vue b/app/assets/javascripts/notes/components/issue_system_note.vue new file mode 100644 index 00000000000..5bb8f871b9d --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_system_note.vue @@ -0,0 +1,55 @@ +<script> + import { mapGetters } from 'vuex'; + import iconsMap from './issue_note_icons'; + import issueNoteHeader from './issue_note_header.vue'; + + export default { + name: 'systemNote', + props: { + note: { + type: Object, + required: true, + }, + }, + components: { + issueNoteHeader, + }, + computed: { + ...mapGetters([ + 'targetNoteHash', + ]), + noteAnchorId() { + return `note_${this.note.id}`; + }, + isTargetNote() { + return this.targetNoteHash === this.noteAnchorId; + }, + }, + created() { + this.svg = iconsMap[this.note.system_note_icon_name]; + }, + }; +</script> + +<template> + <li + :id="noteAnchorId" + :class="{ target: isTargetNote }" + class="note system-note timeline-entry"> + <div class="timeline-entry-inner"> + <div + class="timeline-icon" + v-html="svg"> + </div> + <div class="timeline-content"> + <div class="note-header"> + <issue-note-header + :author="note.author" + :created-at="note.created_at" + :note-id="note.id" + :action-text-html="note.note_html" /> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js new file mode 100644 index 00000000000..a6961063c01 --- /dev/null +++ b/app/assets/javascripts/notes/constants.js @@ -0,0 +1,11 @@ +export const DISCUSSION_NOTE = 'DiscussionNote'; +export const DISCUSSION = 'discussion'; +export const NOTE = 'note'; +export const SYSTEM_NOTE = 'systemNote'; +export const COMMENT = 'comment'; +export const OPENED = 'opened'; +export const REOPENED = 'reopened'; +export const CLOSED = 'closed'; +export const EMOJI_THUMBSUP = 'thumbsup'; +export const EMOJI_THUMBSDOWN = 'thumbsdown'; +export const NOTEABLE_TYPE = 'Issue'; diff --git a/app/assets/javascripts/notes/event_hub.js b/app/assets/javascripts/notes/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/notes/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js new file mode 100644 index 00000000000..e2ea37408cf --- /dev/null +++ b/app/assets/javascripts/notes/index.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import issueNotesApp from './components/issue_notes_app.vue'; + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#js-vue-notes', + components: { + issueNotesApp, + }, + data() { + const notesDataset = document.getElementById('js-vue-notes').dataset; + + return { + issueData: JSON.parse(notesDataset.issueData), + currentUserData: JSON.parse(notesDataset.currentUserData), + notesData: { + lastFetchedAt: notesDataset.lastFetchedAt, + discussionsPath: notesDataset.discussionsPath, + newSessionPath: notesDataset.newSessionPath, + registerPath: notesDataset.registerPath, + notesPath: notesDataset.notesPath, + markdownDocsPath: notesDataset.markdownDocsPath, + quickActionsDocsPath: notesDataset.quickActionsDocsPath, + }, + }; + }, + render(createElement) { + return createElement('issue-notes-app', { + props: { + issueData: this.issueData, + notesData: this.notesData, + userData: this.currentUserData, + }, + }); + }, +})); diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js new file mode 100644 index 00000000000..5843b97f225 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -0,0 +1,16 @@ +/* globals Autosave */ +import '../../autosave'; + +export default { + methods: { + initAutoSave() { + this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue'); + }, + resetAutoSave() { + this.autosave.reset(); + }, + setAutoSave() { + this.autosave.save(); + }, + }, +}; diff --git a/app/assets/javascripts/notes/services/issue_notes_service.js b/app/assets/javascripts/notes/services/issue_notes_service.js new file mode 100644 index 00000000000..b51b0cb2013 --- /dev/null +++ b/app/assets/javascripts/notes/services/issue_notes_service.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default { + fetchNotes(endpoint) { + return Vue.http.get(endpoint); + }, + deleteNote(endpoint) { + return Vue.http.delete(endpoint); + }, + replyToDiscussion(endpoint, data) { + return Vue.http.post(endpoint, data, { emulateJSON: true }); + }, + updateNote(endpoint, data) { + return Vue.http.put(endpoint, data, { emulateJSON: true }); + }, + createNewNote(endpoint, data) { + return Vue.http.post(endpoint, data, { emulateJSON: true }); + }, + poll(data = {}) { + const { endpoint, lastFetchedAt } = data; + const options = { + headers: { + 'X-Last-Fetched-At': lastFetchedAt, + }, + }; + + return Vue.http.get(endpoint, options); + }, + toggleAward(endpoint, data) { + return Vue.http.post(endpoint, data, { emulateJSON: true }); + }, +}; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js new file mode 100644 index 00000000000..13cd74bfa1c --- /dev/null +++ b/app/assets/javascripts/notes/stores/actions.js @@ -0,0 +1,217 @@ +/* global Flash */ +import Visibility from 'visibilityjs'; +import Poll from '../../lib/utils/poll'; +import * as types from './mutation_types'; +import * as utils from './utils'; +import * as constants from '../constants'; +import service from '../services/issue_notes_service'; +import loadAwardsHandler from '../../awards_handler'; +import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; + +let eTagPoll; + +export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data); +export const setIssueData = ({ commit }, data) => commit(types.SET_ISSUE_DATA, data); +export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data); +export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data); +export const setInitialNotes = ({ commit }, data) => commit(types.SET_INITIAL_NOTES, data); +export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data); +export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); + +export const fetchNotes = ({ commit }, path) => service + .fetchNotes(path) + .then(res => res.json()) + .then((res) => { + commit(types.SET_INITIAL_NOTES, res); + }); + +export const deleteNote = ({ commit }, note) => service + .deleteNote(note.path) + .then(() => { + commit(types.DELETE_NOTE, note); + }); + +export const updateNote = ({ commit }, { endpoint, note }) => service + .updateNote(endpoint, note) + .then(res => res.json()) + .then((res) => { + commit(types.UPDATE_NOTE, res); + }); + +export const replyToDiscussion = ({ commit }, { endpoint, data }) => service + .replyToDiscussion(endpoint, data) + .then(res => res.json()) + .then((res) => { + commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res); + + return res; + }); + +export const createNewNote = ({ commit }, { endpoint, data }) => service + .createNewNote(endpoint, data) + .then(res => res.json()) + .then((res) => { + if (!res.errors) { + commit(types.ADD_NEW_NOTE, res); + } + return res; + }); + +export const removePlaceholderNotes = ({ commit }) => + commit(types.REMOVE_PLACEHOLDER_NOTES); + +export const saveNote = ({ commit, dispatch }, noteData) => { + const { note } = noteData.data.note; + let placeholderText = note; + const hasQuickActions = utils.hasQuickActions(placeholderText); + const replyId = noteData.data.in_reply_to_discussion_id; + const methodToDispatch = replyId ? 'replyToDiscussion' : 'createNewNote'; + + commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders + $('.notes-form .flash-container').hide(); // hide previous flash notification + + if (hasQuickActions) { + placeholderText = utils.stripQuickActions(placeholderText); + } + + if (placeholderText.length) { + commit(types.SHOW_PLACEHOLDER_NOTE, { + noteBody: placeholderText, + replyId, + }); + } + + if (hasQuickActions) { + commit(types.SHOW_PLACEHOLDER_NOTE, { + isSystemNote: true, + noteBody: utils.getQuickActionText(note), + replyId, + }); + } + + return dispatch(methodToDispatch, noteData) + .then((res) => { + const { errors } = res; + const commandsChanges = res.commands_changes; + + if (hasQuickActions && errors && Object.keys(errors).length) { + eTagPoll.makeRequest(); + + $('.js-gfm-input').trigger('clear-commands-cache.atwho'); + Flash('Commands applied', 'notice', $(noteData.flashContainer)); + } + + if (commandsChanges) { + if (commandsChanges.emoji_award) { + const votesBlock = $('.js-awards-block').eq(0); + + loadAwardsHandler() + .then((awardsHandler) => { + awardsHandler.addAwardToEmojiBar(votesBlock, commandsChanges.emoji_award); + awardsHandler.scrollToAwards(); + }) + .catch(() => { + Flash( + 'Something went wrong while adding your award. Please try again.', + null, + $(noteData.flashContainer), + ); + }); + } + + if (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) { + sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res); + } + } + + if (errors && errors.commands_only) { + Flash(errors.commands_only, 'notice', $(noteData.flashContainer)); + } + commit(types.REMOVE_PLACEHOLDER_NOTES); + + return res; + }); +}; + +const pollSuccessCallBack = (resp, commit, state, getters) => { + if (resp.notes && resp.notes.length) { + const { notesById } = getters; + + resp.notes.forEach((note) => { + if (notesById[note.id]) { + commit(types.UPDATE_NOTE, note); + } else if (note.type === constants.DISCUSSION_NOTE) { + const discussion = utils.findNoteObjectById(state.notes, note.discussion_id); + + if (discussion) { + commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note); + } else { + commit(types.ADD_NEW_NOTE, note); + } + } else { + commit(types.ADD_NEW_NOTE, note); + } + }); + } + + commit(types.SET_LAST_FETCHED_AT, resp.lastFetchedAt); + + return resp; +}; + +export const poll = ({ commit, state, getters }) => { + const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt }; + + eTagPoll = new Poll({ + resource: service, + method: 'poll', + data: requestData, + successCallback: resp => resp.json() + .then(data => pollSuccessCallBack(data, commit, state, getters)), + errorCallback: () => Flash('Something went wrong while fetching latest comments.'), + }); + + if (!Visibility.hidden()) { + eTagPoll.makeRequest(); + } else { + service.poll(requestData); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + eTagPoll.restart(); + } else { + eTagPoll.stop(); + } + }); +}; + +export const fetchData = ({ commit, state, getters }) => { + const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt }; + + service.poll(requestData) + .then(resp => resp.json) + .then(data => pollSuccessCallBack(data, commit, state, getters)) + .catch(() => Flash('Something went wrong while fetching latest comments.')); +}; + +export const toggleAward = ({ commit, state, getters, dispatch }, { awardName, noteId }) => { + commit(types.TOGGLE_AWARD, { awardName, note: getters.notesById[noteId] }); +}; + +export const toggleAwardRequest = ({ commit, getters, dispatch }, data) => { + const { endpoint, awardName } = data; + + return service + .toggleAward(endpoint, { name: awardName }) + .then(res => res.json()) + .then(() => { + dispatch('toggleAward', data); + }); +}; + +export const scrollToNoteIfNeeded = (context, el) => { + if (!gl.utils.isInViewport(el[0])) { + gl.utils.scrollToElement(el); + } +}; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js new file mode 100644 index 00000000000..1f0c6af6156 --- /dev/null +++ b/app/assets/javascripts/notes/stores/getters.js @@ -0,0 +1,31 @@ +import _ from 'underscore'; + +export const notes = state => state.notes; +export const targetNoteHash = state => state.targetNoteHash; + +export const getNotesData = state => state.notesData; +export const getNotesDataByProp = state => prop => state.notesData[prop]; + +export const getIssueData = state => state.issueData; +export const getIssueDataByProp = state => prop => state.issueData[prop]; + +export const getUserData = state => state.userData || {}; +export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; + +export const notesById = state => state.notes.reduce((acc, note) => { + note.notes.every(n => Object.assign(acc, { [n.id]: n })); + return acc; +}, {}); + +const reverseNotes = array => array.slice(0).reverse(); +const isLastNote = (note, state) => !note.system && + state.userData && note.author && + note.author.id === state.userData.id; + +export const getCurrentUserLastNote = state => _.flatten( + reverseNotes(state.notes) + .map(note => reverseNotes(note.notes)), + ).find(el => isLastNote(el, state)); + +export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes) + .find(el => isLastNote(el, state)); diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js new file mode 100644 index 00000000000..8e0c8531bbc --- /dev/null +++ b/app/assets/javascripts/notes/stores/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + state: { + notes: [], + targetNoteHash: null, + lastFetchedAt: null, + + // holds endpoints and permissions provided through haml + notesData: {}, + userData: {}, + issueData: {}, + }, + actions, + getters, + mutations, +}); diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js new file mode 100644 index 00000000000..cd71533ba9d --- /dev/null +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -0,0 +1,14 @@ +export const ADD_NEW_NOTE = 'ADD_NEW_NOTE'; +export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION'; +export const DELETE_NOTE = 'DELETE_NOTE'; +export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES'; +export const SET_NOTES_DATA = 'SET_NOTES_DATA'; +export const SET_ISSUE_DATA = 'SET_ISSUE_DATA'; +export const SET_USER_DATA = 'SET_USER_DATA'; +export const SET_INITIAL_NOTES = 'SET_INITIAL_NOTES'; +export const SET_LAST_FETCHED_AT = 'SET_LAST_FETCHED_AT'; +export const SET_TARGET_NOTE_HASH = 'SET_TARGET_NOTE_HASH'; +export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE'; +export const TOGGLE_AWARD = 'TOGGLE_AWARD'; +export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; +export const UPDATE_NOTE = 'UPDATE_NOTE'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js new file mode 100644 index 00000000000..3b2b2089d6e --- /dev/null +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -0,0 +1,151 @@ +import * as utils from './utils'; +import * as types from './mutation_types'; +import * as constants from '../constants'; + +export default { + [types.ADD_NEW_NOTE](state, note) { + const { discussion_id, type } = note; + const noteData = { + expanded: true, + id: discussion_id, + individual_note: !(type === constants.DISCUSSION_NOTE), + notes: [note], + reply_id: discussion_id, + }; + + state.notes.push(noteData); + }, + + [types.ADD_NEW_REPLY_TO_DISCUSSION](state, note) { + const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + + if (noteObj) { + noteObj.notes.push(note); + } + }, + + [types.DELETE_NOTE](state, note) { + const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + + if (noteObj.individual_note) { + state.notes.splice(state.notes.indexOf(noteObj), 1); + } else { + const comment = utils.findNoteObjectById(noteObj.notes, note.id); + noteObj.notes.splice(noteObj.notes.indexOf(comment), 1); + + if (!noteObj.notes.length) { + state.notes.splice(state.notes.indexOf(noteObj), 1); + } + } + }, + + [types.REMOVE_PLACEHOLDER_NOTES](state) { + const { notes } = state; + + for (let i = notes.length - 1; i >= 0; i -= 1) { + const note = notes[i]; + const children = note.notes; + + if (children.length && !note.individual_note) { // remove placeholder from discussions + for (let j = children.length - 1; j >= 0; j -= 1) { + if (children[j].isPlaceholderNote) { + children.splice(j, 1); + } + } + } else if (note.isPlaceholderNote) { // remove placeholders from state root + notes.splice(i, 1); + } + } + }, + + [types.SET_NOTES_DATA](state, data) { + Object.assign(state, { notesData: data }); + }, + + [types.SET_ISSUE_DATA](state, data) { + Object.assign(state, { issueData: data }); + }, + + [types.SET_USER_DATA](state, data) { + Object.assign(state, { userData: data }); + }, + [types.SET_INITIAL_NOTES](state, notesData) { + const notes = []; + + notesData.forEach((note) => { + // To support legacy notes, should be very rare case. + if (note.individual_note && note.notes.length > 1) { + note.notes.forEach((n) => { + const nn = Object.assign({}, note); + nn.notes = [n]; // override notes array to only have one item to mimick individual_note + notes.push(nn); + }); + } else { + notes.push(note); + } + }); + + Object.assign(state, { notes }); + }, + + [types.SET_LAST_FETCHED_AT](state, fetchedAt) { + Object.assign(state, { lastFetchedAt: fetchedAt }); + }, + + [types.SET_TARGET_NOTE_HASH](state, hash) { + Object.assign(state, { targetNoteHash: hash }); + }, + + [types.SHOW_PLACEHOLDER_NOTE](state, data) { + let notesArr = state.notes; + if (data.replyId) { + notesArr = utils.findNoteObjectById(notesArr, data.replyId).notes; + } + + notesArr.push({ + individual_note: true, + isPlaceholderNote: true, + placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE, + notes: [ + { + body: data.noteBody, + }, + ], + }); + }, + + [types.TOGGLE_AWARD](state, data) { + const { awardName, note } = data; + const { id, name, username } = state.userData; + + const hasEmojiAwardedByCurrentUser = note.award_emoji + .filter(emoji => emoji.name === data.awardName && emoji.user.id === id); + + if (hasEmojiAwardedByCurrentUser.length) { + // If current user has awarded this emoji, remove it. + note.award_emoji.splice(note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), 1); + } else { + note.award_emoji.push({ + name: awardName, + user: { id, name, username }, + }); + } + }, + + [types.TOGGLE_DISCUSSION](state, { discussionId }) { + const discussion = utils.findNoteObjectById(state.notes, discussionId); + + discussion.expanded = !discussion.expanded; + }, + + [types.UPDATE_NOTE](state, note) { + const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + + if (noteObj.individual_note) { + noteObj.notes.splice(0, 1, note); + } else { + const comment = utils.findNoteObjectById(noteObj.notes, note.id); + noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note); + } + }, +}; diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js new file mode 100644 index 00000000000..6074115e855 --- /dev/null +++ b/app/assets/javascripts/notes/stores/utils.js @@ -0,0 +1,31 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; + +const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; + +export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0]; + +export const getQuickActionText = (note) => { + let text = 'Applying command'; + const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || []; + + const executedCommands = quickActions.filter((command) => { + const commandRegex = new RegExp(`/${command.name}`); + return commandRegex.test(note); + }); + + if (executedCommands && executedCommands.length) { + if (executedCommands.length > 1) { + text = 'Applying multiple commands'; + } else { + const commandDescription = executedCommands[0].description.toLowerCase(); + text = `Applying command to ${commandDescription}`; + } + } + + return text; +}; + +export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note); + +export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim(); + diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue index 7695b04db74..3e5d6d15909 100644 --- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue @@ -72,7 +72,7 @@ }; </script> <template> - <div> + <div class="ci-job-dropdown-container"> <button v-tooltip type="button" diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue index 1f5ed3f1074..3933509a6f4 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue @@ -75,7 +75,7 @@ }; </script> <template> - <div> + <div class="ci-job-component"> <a v-tooltip v-if="job.status.details_path" diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue index d8856e10668..f46d21bd6d7 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue @@ -26,7 +26,7 @@ }; </script> <template> - <span> + <span class="ci-job-name-component"> <ci-icon :status="status" /> diff --git a/app/assets/javascripts/project.js b/app/assets/javascripts/project.js index d7e3ab42f00..fe6602259e2 100644 --- a/app/assets/javascripts/project.js +++ b/app/assets/javascripts/project.js @@ -53,10 +53,6 @@ import Cookies from 'js-cookie'; return _this.changeProject($(e.currentTarget).val()); }; })(this)); - return $('.js-projects-dropdown-toggle').on('click', function(e) { - e.preventDefault(); - return $('.js-projects-dropdown').select2('open'); - }); }; Project.prototype.changeProject = function(url) { diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 1b4ed6be90a..fb01390f91c 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -5,48 +5,6 @@ import ProjectSelectComboButton from './project_select_combo_button'; (function() { this.ProjectSelect = (function() { function ProjectSelect() { - $('.js-projects-dropdown-toggle').each(function(i, dropdown) { - var $dropdown; - $dropdown = $(dropdown); - return $dropdown.glDropdown({ - filterable: true, - filterRemote: true, - search: { - fields: ['name_with_namespace'] - }, - data: function(term, callback) { - var finalCallback, projectsCallback; - var orderBy = $dropdown.data('order-by'); - finalCallback = function(projects) { - return callback(projects); - }; - if (this.includeGroups) { - projectsCallback = function(projects) { - var groupsCallback; - groupsCallback = function(groups) { - var data; - data = groups.concat(projects); - return finalCallback(data); - }; - return Api.groups(term, {}, groupsCallback); - }; - } else { - projectsCallback = finalCallback; - } - if (this.groupId) { - return Api.groupProjects(this.groupId, term, projectsCallback); - } else { - return Api.projects(term, { order_by: orderBy }, projectsCallback); - } - }, - url: function(project) { - return project.web_url; - }, - text: function(project) { - return project.name_with_namespace; - } - }); - }); $('.ajax-project-select').each(function(i, select) { var placeholder; this.groupId = $(select).data('group-id'); diff --git a/app/assets/javascripts/projects_dropdown/components/app.vue b/app/assets/javascripts/projects_dropdown/components/app.vue new file mode 100644 index 00000000000..7606605be32 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/app.vue @@ -0,0 +1,157 @@ +<script> +import bs from '../../breakpoints'; +import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + +import projectsListFrequent from './projects_list_frequent.vue'; +import projectsListSearch from './projects_list_search.vue'; + +import search from './search.vue'; + +export default { + components: { + search, + loadingIcon, + projectsListFrequent, + projectsListSearch, + }, + props: { + currentProject: { + type: Object, + required: true, + }, + store: { + type: Object, + required: true, + }, + service: { + type: Object, + required: true, + }, + }, + data() { + return { + isLoadingProjects: false, + isFrequentsListVisible: false, + isSearchListVisible: false, + isLocalStorageFailed: false, + isSearchFailed: false, + searchQuery: '', + }; + }, + computed: { + frequentProjects() { + return this.store.getFrequentProjects(); + }, + searchProjects() { + return this.store.getSearchedProjects(); + }, + }, + methods: { + toggleFrequentProjectsList(state) { + this.isLoadingProjects = !state; + this.isSearchListVisible = !state; + this.isFrequentsListVisible = state; + }, + toggleSearchProjectsList(state) { + this.isLoadingProjects = !state; + this.isFrequentsListVisible = !state; + this.isSearchListVisible = state; + }, + toggleLoader(state) { + this.isFrequentsListVisible = !state; + this.isSearchListVisible = !state; + this.isLoadingProjects = state; + }, + fetchFrequentProjects() { + const screenSize = bs.getBreakpointSize(); + if (this.searchQuery && (screenSize !== 'sm' && screenSize !== 'xs')) { + this.toggleSearchProjectsList(true); + } else { + this.toggleLoader(true); + this.isLocalStorageFailed = false; + const projects = this.service.getFrequentProjects(); + if (projects) { + this.toggleFrequentProjectsList(true); + this.store.setFrequentProjects(projects); + } else { + this.isLocalStorageFailed = true; + this.toggleFrequentProjectsList(true); + this.store.setFrequentProjects([]); + } + } + }, + fetchSearchedProjects(searchQuery) { + this.searchQuery = searchQuery; + this.toggleLoader(true); + this.service.getSearchedProjects(this.searchQuery) + .then(res => res.json()) + .then((results) => { + this.toggleSearchProjectsList(true); + this.store.setSearchedProjects(results); + }) + .catch(() => { + this.isSearchFailed = true; + this.toggleSearchProjectsList(true); + }); + }, + logCurrentProjectAccess() { + this.service.logProjectAccess(this.currentProject); + }, + handleSearchClear() { + this.searchQuery = ''; + this.toggleFrequentProjectsList(true); + this.store.clearSearchedProjects(); + }, + handleSearchFailure() { + this.isSearchFailed = true; + this.toggleSearchProjectsList(true); + }, + }, + created() { + if (this.currentProject.id) { + this.logCurrentProjectAccess(); + } + + eventHub.$on('dropdownOpen', this.fetchFrequentProjects); + eventHub.$on('searchProjects', this.fetchSearchedProjects); + eventHub.$on('searchCleared', this.handleSearchClear); + eventHub.$on('searchFailed', this.handleSearchFailure); + }, + beforeDestroy() { + eventHub.$off('dropdownOpen', this.fetchFrequentProjects); + eventHub.$off('searchProjects', this.fetchSearchedProjects); + eventHub.$off('searchCleared', this.handleSearchClear); + eventHub.$off('searchFailed', this.handleSearchFailure); + }, +}; +</script> + +<template> + <div> + <search/> + <loading-icon + class="loading-animation prepend-top-20" + size="2" + v-if="isLoadingProjects" + :label="s__('ProjectsDropdown|Loading projects')" + /> + <div + class="section-header" + v-if="isFrequentsListVisible" + > + {{ s__('ProjectsDropdown|Frequently visited') }} + </div> + <projects-list-frequent + v-if="isFrequentsListVisible" + :local-storage-failed="isLocalStorageFailed" + :projects="frequentProjects" + /> + <projects-list-search + v-if="isSearchListVisible" + :search-failed="isSearchFailed" + :matcher="searchQuery" + :projects="searchProjects" + /> + </div> +</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue new file mode 100644 index 00000000000..093554cd0bc --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/projects_list_frequent.vue @@ -0,0 +1,57 @@ +<script> +import { s__ } from '../../locale'; +import projectsListItem from './projects_list_item.vue'; + +export default { + components: { + projectsListItem, + }, + props: { + projects: { + type: Array, + required: true, + }, + localStorageFailed: { + type: Boolean, + required: true, + }, + }, + computed: { + isListEmpty() { + return this.projects.length === 0; + }, + listEmptyMessage() { + return this.localStorageFailed ? + s__('ProjectsDropdown|This feature requires browser localStorage support') : + s__('ProjectsDropdown|Projects you visit often will appear here'); + }, + }, +}; +</script> + +<template> + <div + class="projects-list-frequent-container" + > + <ul + class="list-unstyled" + > + <li + class="section-empty" + v-if="isListEmpty" + > + {{listEmptyMessage}} + </li> + <projects-list-item + v-else + v-for="(project, index) in projects" + :key="index" + :project-id="project.id" + :project-name="project.name" + :namespace="project.namespace" + :web-url="project.webUrl" + :avatar-url="project.avatarUrl" + /> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue new file mode 100644 index 00000000000..fe5179de206 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue @@ -0,0 +1,96 @@ +<script> +import identicon from '../../vue_shared/components/identicon.vue'; + +export default { + components: { + identicon, + }, + props: { + matcher: { + type: String, + required: false, + }, + projectId: { + type: Number, + required: true, + }, + projectName: { + type: String, + required: true, + }, + namespace: { + type: String, + required: true, + }, + webUrl: { + type: String, + required: true, + }, + avatarUrl: { + required: true, + validator(value) { + return value === null || typeof value === 'string'; + }, + }, + }, + computed: { + hasAvatar() { + return this.avatarUrl !== null; + }, + highlightedProjectName() { + if (this.matcher) { + const matcherRegEx = new RegExp(this.matcher, 'gi'); + const matches = this.projectName.match(matcherRegEx); + + if (matches && matches.length > 0) { + return this.projectName.replace(matches[0], `<b>${matches[0]}</b>`); + } + } + return this.projectName; + }, + }, +}; +</script> + +<template> + <li + class="projects-list-item-container" + > + <a + class="clearfix" + :href="webUrl" + > + <div + class="project-item-avatar-container" + > + <img + v-if="hasAvatar" + class="avatar s32" + :src="avatarUrl" + /> + <identicon + v-else + size-class="s32" + :entity-id=projectId + :entity-name="projectName" + /> + </div> + <div + class="project-item-metadata-container" + > + <div + class="project-title" + :title="projectName" + v-html="highlightedProjectName" + > + </div> + <div + class="project-namespace" + :title="namespace" + > + {{namespace}} + </div> + </div> + </a> + </li> +</template> diff --git a/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue new file mode 100644 index 00000000000..fa5efef2919 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/projects_list_search.vue @@ -0,0 +1,63 @@ +<script> +import { s__ } from '../../locale'; +import projectsListItem from './projects_list_item.vue'; + +export default { + components: { + projectsListItem, + }, + props: { + matcher: { + type: String, + required: true, + }, + projects: { + type: Array, + required: true, + }, + searchFailed: { + type: Boolean, + required: true, + }, + }, + computed: { + isListEmpty() { + return this.projects.length === 0; + }, + listEmptyMessage() { + return this.searchFailed ? + s__('ProjectsDropdown|Something went wrong on our end.') : + s__('ProjectsDropdown|No projects matched your query'); + }, + }, +}; +</script> + +<template> + <div + class="projects-list-search-container" + > + <ul + class="list-unstyled" + > + <li + v-if="isListEmpty" + :class="{ 'section-failure': searchFailed }" + class="section-empty" + > + {{ listEmptyMessage }} + </li> + <projects-list-item + v-else + v-for="(project, index) in projects" + :key="index" + :project-id="project.id" + :project-name="project.name" + :namespace="project.namespace" + :web-url="project.webUrl" + :avatar-url="project.avatarUrl" + :matcher="matcher" + /> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/projects_dropdown/components/search.vue b/app/assets/javascripts/projects_dropdown/components/search.vue new file mode 100644 index 00000000000..b71997234e5 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/components/search.vue @@ -0,0 +1,64 @@ +<script> +import _ from 'underscore'; +import eventHub from '../event_hub'; + +export default { + data() { + return { + searchQuery: '', + }; + }, + watch: { + searchQuery() { + this.handleInput(); + }, + }, + methods: { + setFocus() { + this.$refs.search.focus(); + }, + emitSearchEvents() { + if (this.searchQuery) { + eventHub.$emit('searchProjects', this.searchQuery); + } else { + eventHub.$emit('searchCleared'); + } + }, + /** + * Callback function within _.debounce is intentionally + * kept as ES5 `function() {}` instead of ES6 `() => {}` + * as it otherwise messes up function context + * and component reference is no longer accessible via `this` + */ + // eslint-disable-next-line func-names + handleInput: _.debounce(function () { + this.emitSearchEvents(); + }, 500), + }, + mounted() { + eventHub.$on('dropdownOpen', this.setFocus); + }, + beforeDestroy() { + eventHub.$off('dropdownOpen', this.setFocus); + }, +}; +</script> + +<template> + <div + class="search-input-container hidden-xs" + > + <input + type="search" + class="form-control" + ref="search" + v-model="searchQuery" + :placeholder="s__('ProjectsDropdown|Search projects')" + /> + <i + v-if="!searchQuery" + class="search-icon fa fa-fw fa-search" + aria-hidden="true" + /> + </div> +</template> diff --git a/app/assets/javascripts/projects_dropdown/constants.js b/app/assets/javascripts/projects_dropdown/constants.js new file mode 100644 index 00000000000..8937097184c --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/constants.js @@ -0,0 +1,10 @@ +export const FREQUENT_PROJECTS = { + MAX_COUNT: 20, + LIST_COUNT_DESKTOP: 5, + LIST_COUNT_MOBILE: 3, + ELIGIBLE_FREQUENCY: 3, +}; + +export const HOUR_IN_MS = 3600000; + +export const STORAGE_KEY = 'frequent-projects'; diff --git a/app/assets/javascripts/projects_dropdown/event_hub.js b/app/assets/javascripts/projects_dropdown/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/projects_dropdown/index.js b/app/assets/javascripts/projects_dropdown/index.js new file mode 100644 index 00000000000..2660da3c558 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/index.js @@ -0,0 +1,68 @@ +import Vue from 'vue'; + +import Translate from '../vue_shared/translate'; +import eventHub from './event_hub'; +import ProjectsService from './service/projects_service'; +import ProjectsStore from './store/projects_store'; + +import projectsDropdownApp from './components/app.vue'; + +Vue.use(Translate); + +document.addEventListener('DOMContentLoaded', () => { + const el = document.getElementById('js-projects-dropdown'); + const navEl = document.getElementById('nav-projects-dropdown'); + + // Don't do anything if element doesn't exist (No projects dropdown) + // This is for when the user accesses GitLab without logging in + if (!el || !navEl) { + return; + } + + $(navEl).on('show.bs.dropdown', (e) => { + const dropdownEl = $(e.currentTarget).find('.projects-dropdown-menu'); + dropdownEl.one('transitionend', () => { + eventHub.$emit('dropdownOpen'); + }); + }); + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + projectsDropdownApp, + }, + data() { + const dataset = this.$options.el.dataset; + const store = new ProjectsStore(); + const service = new ProjectsService(dataset.userName); + + const project = { + id: Number(dataset.projectId), + name: dataset.projectName, + namespace: dataset.projectNamespace, + webUrl: dataset.projectWebUrl, + avatarUrl: dataset.projectAvatarUrl || null, + lastAccessedOn: Date.now(), + }; + + return { + store, + service, + state: store.state, + currentUserName: dataset.userName, + currentProject: project, + }; + }, + render(createElement) { + return createElement('projects-dropdown-app', { + props: { + currentUserName: this.currentUserName, + currentProject: this.currentProject, + store: this.store, + service: this.service, + }, + }); + }, + }); +}); diff --git a/app/assets/javascripts/projects_dropdown/service/projects_service.js b/app/assets/javascripts/projects_dropdown/service/projects_service.js new file mode 100644 index 00000000000..fad956b4c26 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/service/projects_service.js @@ -0,0 +1,132 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +import bp from '../../breakpoints'; +import Api from '../../api'; +import AccessorUtilities from '../../lib/utils/accessor'; + +import { FREQUENT_PROJECTS, HOUR_IN_MS, STORAGE_KEY } from '../constants'; + +Vue.use(VueResource); + +export default class ProjectsService { + constructor(currentUserName) { + this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); + this.currentUserName = currentUserName; + this.storageKey = `${this.currentUserName}/${STORAGE_KEY}`; + this.projectsPath = Vue.resource(Api.buildUrl(Api.projectsPath)); + } + + getSearchedProjects(searchQuery) { + return this.projectsPath.get({ + simple: false, + per_page: 20, + membership: !!gon.current_user_id, + order_by: 'last_activity_at', + search: searchQuery, + }); + } + + getFrequentProjects() { + if (this.isLocalStorageAvailable) { + return this.getTopFrequentProjects(); + } + return null; + } + + logProjectAccess(project) { + let matchFound = false; + let storedFrequentProjects; + + if (this.isLocalStorageAvailable) { + const storedRawProjects = localStorage.getItem(this.storageKey); + + // Check if there's any frequent projects list set + if (!storedRawProjects) { + // No frequent projects list set, set one up. + storedFrequentProjects = []; + storedFrequentProjects.push({ ...project, frequency: 1 }); + } else { + // Check if project is already present in frequents list + // When found, update metadata of it. + storedFrequentProjects = JSON.parse(storedRawProjects).map((projectItem) => { + if (projectItem.id === project.id) { + matchFound = true; + const diff = Math.abs(project.lastAccessedOn - projectItem.lastAccessedOn) / HOUR_IN_MS; + const updatedProject = { + ...project, + frequency: projectItem.frequency, + lastAccessedOn: projectItem.lastAccessedOn, + }; + + // Check if duration since last access of this project + // is over an hour + if (diff > 1) { + return { + ...updatedProject, + frequency: updatedProject.frequency + 1, + lastAccessedOn: Date.now(), + }; + } + + return { + ...updatedProject, + }; + } + + return projectItem; + }); + + // Check whether currently logged project is present in frequents list + if (!matchFound) { + // We always keep size of frequents collection to 20 projects + // out of which only 5 projects with + // highest value of `frequency` and most recent `lastAccessedOn` + // are shown in projects dropdown + if (storedFrequentProjects.length === FREQUENT_PROJECTS.MAX_COUNT) { + storedFrequentProjects.shift(); // Remove an item from head of array + } + + storedFrequentProjects.push({ ...project, frequency: 1 }); + } + } + + localStorage.setItem(this.storageKey, JSON.stringify(storedFrequentProjects)); + } + } + + getTopFrequentProjects() { + const storedFrequentProjects = JSON.parse(localStorage.getItem(this.storageKey)); + let frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_DESKTOP; + + if (!storedFrequentProjects) { + return []; + } + + if (bp.getBreakpointSize() === 'sm' || + bp.getBreakpointSize() === 'xs') { + frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_MOBILE; + } + + const frequentProjects = storedFrequentProjects + .filter(project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY); + + // Sort all frequent projects in decending order of frequency + // and then by lastAccessedOn with recent most first + frequentProjects.sort((projectA, projectB) => { + if (projectA.frequency < projectB.frequency) { + return 1; + } else if (projectA.frequency > projectB.frequency) { + return -1; + } else if (projectA.lastAccessedOn < projectB.lastAccessedOn) { + return 1; + } else if (projectA.lastAccessedOn > projectB.lastAccessedOn) { + return -1; + } + + return 0; + }); + + return _.first(frequentProjects, frequentProjectsCount); + } +} diff --git a/app/assets/javascripts/projects_dropdown/store/projects_store.js b/app/assets/javascripts/projects_dropdown/store/projects_store.js new file mode 100644 index 00000000000..ffefbe693f4 --- /dev/null +++ b/app/assets/javascripts/projects_dropdown/store/projects_store.js @@ -0,0 +1,33 @@ +export default class ProjectsStore { + constructor() { + this.state = {}; + this.state.frequentProjects = []; + this.state.searchedProjects = []; + } + + setFrequentProjects(rawProjects) { + this.state.frequentProjects = rawProjects; + } + + getFrequentProjects() { + return this.state.frequentProjects; + } + + setSearchedProjects(rawProjects) { + this.state.searchedProjects = rawProjects.map(rawProject => ({ + id: rawProject.id, + name: rawProject.name, + namespace: rawProject.name_with_namespace, + webUrl: rawProject.web_url, + avatarUrl: rawProject.avatar_url, + })); + } + + getSearchedProjects() { + return this.state.searchedProjects; + } + + clearSearchedProjects() { + this.state.searchedProjects = []; + } +} diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index fa958d75fa4..4c87d46c96e 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -157,11 +157,16 @@ import SidebarHeightManager from './sidebar_height_manager'; Sidebar.prototype.openDropdown = function(blockOrName) { var $block; $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; - $block.find('.edit-link').trigger('click'); if (!this.isOpen()) { this.setCollapseAfterUpdate($block); - return this.toggleSidebar('open'); + this.toggleSidebar('open'); } + + // Wait for the sidebar to trigger('click') open + // so it doesn't cause our dropdown to close preemptively + setTimeout(() => { + $block.find('.js-sidebar-dropdown-toggle').trigger('click'); + }); }; Sidebar.prototype.setCollapseAfterUpdate = function($block) { diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index 0be141eb5f9..78b257bf192 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -20,7 +20,7 @@ import './shortcuts_navigation'; Mousetrap.bind('m', this.openSidebarDropdown.bind(this, 'milestone')); Mousetrap.bind('r', (function(_this) { return function() { - _this.replyWithSelectedText(); + _this.replyWithSelectedText(isMergeRequest); return false; }; })(this)); @@ -38,9 +38,15 @@ import './shortcuts_navigation'; } } - ShortcutsIssuable.prototype.replyWithSelectedText = function() { + ShortcutsIssuable.prototype.replyWithSelectedText = function(isMergeRequest) { var quote, documentFragment, el, selected, separator; - var replyField = $('.js-main-target-form #note_note'); + let replyField; + + if (isMergeRequest) { + replyField = $('.js-main-target-form #note_note'); + } else { + replyField = $('.js-main-target-form .js-vue-comment-form'); + } documentFragment = window.gl.utils.getSelectedFragment(); if (!documentFragment) { @@ -57,6 +63,7 @@ import './shortcuts_navigation'; quote = _.map(selected.split("\n"), function(val) { return ("> " + val).trim() + "\n"; }); + // If replyField already has some content, add a newline before our quote separator = replyField.val().trim() !== "" && "\n\n" || ''; replyField.val(function(a, current) { @@ -64,7 +71,7 @@ import './shortcuts_navigation'; }); // Trigger autosave - replyField.trigger('input'); + replyField.trigger('input').trigger('change'); // Trigger autosize var event = document.createEvent('Event'); diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js index 5a6e47e566e..77f070d48cc 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js @@ -36,7 +36,7 @@ export default { /> <a v-if="editable" - class="edit-link pull-right" + class="js-sidebar-dropdown-toggle edit-link pull-right" href="#" > Edit diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js index 2d682215cf8..d32fe4abc7d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js @@ -6,6 +6,7 @@ import timeTracker from './time_tracker'; import Store from '../../stores/sidebar_store'; import Mediator from '../../sidebar_mediator'; +import eventHub from '../../event_hub'; export default { data() { @@ -20,6 +21,9 @@ export default { methods: { listenForQuickActions() { $(document).on('ajax:success', '.gfm-form', this.quickActionListened); + eventHub.$on('timeTrackingUpdated', (data) => { + this.quickActionListened(null, data); + }); }, quickActionListened(e, data) { const subscribedCommands = ['spend_time', 'time_estimate']; diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js new file mode 100644 index 00000000000..1c15a1b877a --- /dev/null +++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js @@ -0,0 +1,85 @@ +/* global Flash */ + +function isValidProjectId(id) { + return id > 0; +} + +class SidebarMoveIssue { + constructor(mediator, dropdownToggle, confirmButton) { + this.mediator = mediator; + + this.$dropdownToggle = $(dropdownToggle); + this.$confirmButton = $(confirmButton); + + this.onConfirmClickedWrapper = this.onConfirmClicked.bind(this); + } + + init() { + this.initDropdown(); + this.addEventListeners(); + } + + destroy() { + this.removeEventListeners(); + } + + initDropdown() { + this.$dropdownToggle.glDropdown({ + search: { + fields: ['name_with_namespace'], + }, + showMenuAbove: true, + selectable: true, + filterable: true, + filterRemote: true, + multiSelect: false, + // Keep the dropdown open after selecting an option + shouldPropagate: false, + data: (searchTerm, callback) => { + this.mediator.fetchAutocompleteProjects(searchTerm) + .then(callback) + .catch(() => new Flash('An error occured while fetching projects autocomplete.')); + }, + renderRow: project => ` + <li> + <a href="#" class="js-move-issue-dropdown-item"> + ${project.name_with_namespace} + </a> + </li> + `, + clicked: (options) => { + const project = options.selectedObj; + const selectedProjectId = options.isMarking ? project.id : 0; + this.mediator.setMoveToProjectId(selectedProjectId); + + this.$confirmButton.attr('disabled', !isValidProjectId(selectedProjectId)); + }, + }); + } + + addEventListeners() { + this.$confirmButton.on('click', this.onConfirmClickedWrapper); + } + + removeEventListeners() { + this.$confirmButton.off('click', this.onConfirmClickedWrapper); + } + + onConfirmClicked() { + if (isValidProjectId(this.mediator.store.moveToProjectId)) { + this.$confirmButton + .disable() + .addClass('is-loading'); + + this.mediator.moveIssue() + .catch(() => { + Flash('An error occured while moving the issue.'); + this.$confirmButton + .enable() + .removeClass('is-loading'); + }); + } + } +} + +export default SidebarMoveIssue; diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index 5a82d01dc41..604648407a4 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -4,9 +4,11 @@ import VueResource from 'vue-resource'; Vue.use(VueResource); export default class SidebarService { - constructor(endpoint) { + constructor(endpointMap) { if (!SidebarService.singleton) { - this.endpoint = endpoint; + this.endpoint = endpointMap.endpoint; + this.moveIssueEndpoint = endpointMap.moveIssueEndpoint; + this.projectsAutocompleteEndpoint = endpointMap.projectsAutocompleteEndpoint; SidebarService.singleton = this; } @@ -25,4 +27,18 @@ export default class SidebarService { emulateJSON: true, }); } + + getProjectsAutocomplete(searchTerm) { + return Vue.http.get(this.projectsAutocompleteEndpoint, { + params: { + search: searchTerm, + }, + }); + } + + moveIssue(moveToProjectId) { + return Vue.http.post(this.moveIssueEndpoint, { + move_to_project_id: moveToProjectId, + }); + } } diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js index 9edded3ead6..3d8972050a9 100644 --- a/app/assets/javascripts/sidebar/sidebar_bundle.js +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking'; import sidebarAssignees from './components/assignees/sidebar_assignees'; import confidential from './components/confidential/confidential_issue_sidebar.vue'; +import SidebarMoveIssue from './lib/sidebar_move_issue'; import Mediator from './sidebar_mediator'; @@ -31,6 +32,12 @@ function domContentLoaded() { service: mediator.service, }, }).$mount(confidentialEl); + + new SidebarMoveIssue( + mediator, + $('.js-move-issue'), + $('.js-move-issue-confirmation-button'), + ).init(); } new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker'); diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 721e92221cf..e38a8db4cc5 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -7,7 +7,11 @@ export default class SidebarMediator { constructor(options) { if (!SidebarMediator.singleton) { this.store = new Store(options); - this.service = new Service(options.endpoint); + this.service = new Service({ + endpoint: options.endpoint, + moveIssueEndpoint: options.moveIssueEndpoint, + projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint, + }); SidebarMediator.singleton = this; } @@ -26,6 +30,10 @@ export default class SidebarMediator { return this.service.update(field, selected.length === 0 ? [0] : selected); } + setMoveToProjectId(projectId) { + this.store.setMoveToProjectId(projectId); + } + fetch() { this.service.get() .then(response => response.json()) @@ -35,4 +43,23 @@ export default class SidebarMediator { }) .catch(() => new Flash('Error occured when fetching sidebar data')); } + + fetchAutocompleteProjects(searchTerm) { + return this.service.getProjectsAutocomplete(searchTerm) + .then(response => response.json()) + .then((data) => { + this.store.setAutocompleteProjects(data); + return this.store.autocompleteProjects; + }); + } + + moveIssue() { + return this.service.moveIssue(this.store.moveToProjectId) + .then(response => response.json()) + .then((data) => { + if (location.pathname !== data.web_url) { + gl.utils.visitUrl(data.web_url); + } + }); + } } diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 3356dd0191f..cc04a2a3fcf 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -13,6 +13,8 @@ export default class SidebarStore { this.isFetching = { assignees: true, }; + this.autocompleteProjects = []; + this.moveToProjectId = 0; SidebarStore.singleton = this; } @@ -53,4 +55,12 @@ export default class SidebarStore { removeAllAssignees() { this.assignees = []; } + + setAutocompleteProjects(projects) { + this.autocompleteProjects = projects; + } + + setMoveToProjectId(moveToProjectId) { + this.moveToProjectId = moveToProjectId; + } } 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 index c05a76a3b4a..aaca42e3ebc 100644 --- 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 @@ -75,18 +75,20 @@ export default { class="btn btn-small inline"> Check out branch </a> - <span class="dropdown inline prepend-left-10"> + <span class="dropdown prepend-left-10"> <a - class="btn btn-xs dropdown-toggle" + class="btn btn-small inline dropdown-toggle" data-toggle="dropdown" aria-label="Download as" role="button"> <i class="fa fa-download" - aria-hidden="true" /> + aria-hidden="true"> + </i> <i class="fa fa-caret-down" - aria-hidden="true" /> + aria-hidden="true"> + </i> </a> <ul class="dropdown-menu dropdown-menu-align-right"> <li> diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue index 0edd820743f..7cf2e029cf6 100644 --- a/app/assets/javascripts/vue_shared/components/identicon.vue +++ b/app/assets/javascripts/vue_shared/components/identicon.vue @@ -9,6 +9,11 @@ export default { type: String, required: true, }, + sizeClass: { + type: String, + required: false, + default: 's40', + }, }, computed: { /** @@ -38,7 +43,8 @@ export default { <template> <div - class="avatar s40 identicon" + class="avatar identicon" + :class="sizeClass" :style="identiconStyles"> {{identiconTitle}} </div> diff --git a/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue new file mode 100644 index 00000000000..397d16331d5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue @@ -0,0 +1,16 @@ +<script> + export default { + name: 'confidentialIssueWarning', + }; +</script> +<template> + <div class="confidential-issue-warning"> + <i + aria-hidden="true" + class="fa fa-eye-slash"> + </i> + <span> + This is a confidential issue. Your comment will not be visible to the public. + </span> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 4e10bbc7408..759d30c9c7c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -5,19 +5,30 @@ export default { props: { - markdownPreviewUrl: { + markdownPreviewPath: { type: String, required: false, default: '', }, - markdownDocs: { + markdownDocsPath: { type: String, required: true, }, + addSpacingClasses: { + type: Boolean, + required: false, + default: true, + }, + quickActionsDocsPath: { + type: String, + required: false, + }, }, data() { return { markdownPreview: '', + referencedCommands: '', + referencedUsers: '', markdownPreviewLoading: false, previewMarkdown: false, }; @@ -26,35 +37,48 @@ markdownHeader, markdownToolbar, }, + computed: { + shouldShowReferencedUsers() { + const referencedUsersThreshold = 10; + return this.referencedUsers.length >= referencedUsersThreshold; + }, + }, methods: { toggleMarkdownPreview() { this.previewMarkdown = !this.previewMarkdown; + /* + Can't use `$refs` as the component is technically in the parent component + so we access the VNode & then get the element + */ + const text = this.$slots.textarea[0].elm.value; + if (!this.previewMarkdown) { this.markdownPreview = ''; - } else { + } else if (text) { this.markdownPreviewLoading = true; - this.$http.post( - this.markdownPreviewUrl, - { - /* - Can't use `$refs` as the component is technically in the parent component - so we access the VNode & then get the element - */ - text: this.$slots.textarea[0].elm.value, - }, - ) - .then(resp => resp.json()) - .then((data) => { - this.markdownPreviewLoading = false; - this.markdownPreview = data.body; + this.$http.post(this.markdownPreviewPath, { text }) + .then(resp => resp.json()) + .then((data) => { + this.renderMarkdown(data); + }) + .catch(() => new Flash('Error loading markdown preview')); + } else { + this.renderMarkdown(); + } + }, + renderMarkdown(data = {}) { + this.markdownPreviewLoading = false; + this.markdownPreview = data.body || 'Nothing to preview.'; - this.$nextTick(() => { - $(this.$refs['markdown-preview']).renderGFM(); - }); - }) - .catch(() => new Flash('Error loading markdown preview')); + if (data.references) { + this.referencedCommands = data.references.commands; + this.referencedUsers = data.references.users; } + + this.$nextTick(() => { + $(this.$refs['markdown-preview']).renderGFM(); + }); }, }, mounted() { @@ -74,7 +98,8 @@ <template> <div - class="md-area prepend-top-default append-bottom-default js-vue-markdown-field" + class="md-area js-vue-markdown-field" + :class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }" ref="gl-form"> <markdown-header :preview-markdown="previewMarkdown" @@ -94,7 +119,9 @@ </i> </a> <markdown-toolbar - :markdown-docs="markdownDocs" /> + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + /> </div> </div> <div @@ -108,5 +135,27 @@ Loading... </span> </div> + <template v-if="previewMarkdown && !markdownPreviewLoading"> + <div + v-if="referencedCommands" + v-html="referencedCommands" + class="referenced-commands"></div> + <div + v-if="shouldShowReferencedUsers" + class="referenced-users"> + <span> + <i + class="fa fa-exclamation-triangle" + aria-hidden="true"> + </i> + You are about to add + <strong> + <span class="js-referenced-users-count"> + {{referencedUsers.length}} + </span> + </strong> people to the discussion. Proceed with caution. + </span> + </div> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 93252293ba6..65fe7bbd94e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -1,10 +1,14 @@ <script> export default { props: { - markdownDocs: { + markdownDocsPath: { type: String, required: true, }, + quickActionsDocsPath: { + type: String, + required: false, + }, }, }; </script> @@ -12,22 +16,77 @@ <template> <div class="comment-toolbar clearfix"> <div class="toolbar-text"> - <a - :href="markdownDocs" - target="_blank" - tabindex="-1"> - Markdown is supported - </a> + <template v-if="!quickActionsDocsPath && markdownDocsPath"> + <a + :href="markdownDocsPath" + target="_blank" + tabindex="-1"> + Markdown is supported + </a> + </template> + <template v-if="quickActionsDocsPath && markdownDocsPath"> + <a + :href="markdownDocsPath" + target="_blank" + tabindex="-1"> + Markdown + </a> + and + <a + :href="quickActionsDocsPath" + target="_blank" + tabindex="-1"> + quick actions + </a> + are supported + </template> </div> - <button - class="toolbar-button markdown-selector" - type="button" - tabindex="-1"> - <i - class="fa fa-file-image-o toolbar-button-icon" - aria-hidden="true"> - </i> - Attach a file - </button> + <span class="uploading-container"> + <span class="uploading-progress-container hide"> + <i + class="fa fa-file-image-o toolbar-button-icon" + aria-hidden="true"></i> + <span class="attaching-file-message"></span> + <span class="uploading-progress">0%</span> + <span class="uploading-spinner"> + <i + class="fa fa-spinner fa-spin toolbar-button-icon" + aria-hidden="true"></i> + </span> + </span> + <span class="uploading-error-container hide"> + <span class="uploading-error-icon"> + <i + class="fa fa-file-image-o toolbar-button-icon" + aria-hidden="true"></i> + </span> + <span class="uploading-error-message"></span> + <button + class="retry-uploading-link" + type="button"> + Try again + </button> + or + <button + class="attach-new-file markdown-selector" + type="button"> + attach a new file + </button> + </span> + <button + class="markdown-selector button-attach-file" + tabindex="-1" + type="button"> + <i + class="fa fa-file-image-o toolbar-button-icon" + aria-hidden="true"></i> + Attach a file + </button> + <button + class="btn btn-default btn-xs hide button-cancel-uploading-files" + type="button"> + Cancel + </button> + </span> </div> </template> diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index b2b3297e880..c0524bf6aa3 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -51,3 +51,4 @@ @import "framework/snippets"; @import "framework/memory_graph"; @import "framework/responsive-tables"; +@import "framework/feature_highlight"; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index b4a6b214e98..82350c36df0 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -46,6 +46,15 @@ } } +@mixin btn-svg { + svg { + height: 15px; + width: 15px; + position: relative; + top: 2px; + } +} + @mixin btn-color($light, $border-light, $normal, $border-normal, $dark, $border-dark, $color) { background-color: $light; border-color: $border-light; @@ -123,6 +132,7 @@ .btn { @include btn-default; @include btn-white; + @include btn-svg; color: $gl-text-color; @@ -222,13 +232,6 @@ } } - svg { - height: 15px; - width: 15px; - position: relative; - top: 2px; - } - svg, .fa { &:not(:last-child) { diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index 4ce767e4cc4..c165ec0b94b 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -95,8 +95,8 @@ .is-selected .pika-day, .pika-day:hover, .is-today .pika-day { - background: $gl-primary; - color: $white-light; + background: $gray-darker; + color: $gl-text-color; box-shadow: none; } } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index e16fbbf43b5..a85051642dd 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -16,10 +16,12 @@ .prepend-left-default { margin-left: $gl-padding; } .prepend-left-20 { margin-left: 20px; } .append-right-5 { margin-right: 5px; } +.append-right-8 { margin-right: 8px; } .append-right-10 { margin-right: 10px; } .append-right-default { margin-right: $gl-padding; } .append-right-20 { margin-right: 20px; } .append-bottom-0 { margin-bottom: 0; } +.append-bottom-5 { margin-bottom: 5px; } .append-bottom-10 { margin-bottom: 10px; } .append-bottom-15 { margin-bottom: 15px; } .append-bottom-20 { margin-bottom: 20px; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 5871383a57b..e65a78b8dc3 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -193,7 +193,7 @@ min-width: 240px; max-width: 500px; margin-top: 2px; - margin-bottom: 0; + margin-bottom: 2px; font-size: 14px; font-weight: $gl-font-weight-normal; padding: 8px 0; @@ -368,6 +368,10 @@ transform: translateY(0); } +.comment-type-dropdown.open .dropdown-menu { + display: block; +} + .filtered-search-box-input-container { .dropdown-menu, .dropdown-menu-nav { @@ -618,6 +622,11 @@ border-top: 1px solid $dropdown-divider-color; } +.dropdown-footer-content { + padding-left: 10px; + padding-right: 10px; +} + .dropdown-due-date-footer { padding-top: 0; margin-left: 10px; @@ -728,7 +737,10 @@ @mixin new-style-dropdown($selector: '') { #{$selector}.dropdown-menu, #{$selector}.dropdown-menu-nav { + margin-bottom: 24px; + li { + display: block; padding: 0 1px; &:hover { @@ -748,13 +760,17 @@ } a, - button { + button, + .menu-item { border-radius: 0; + box-shadow: none; padding: 8px 16px; + text-align: left; + width: 100%; // make sure the text color is not overriden &.text-danger { - @extend .text-danger; + color: $brand-danger; } &.is-focused, @@ -763,6 +779,11 @@ &:focus { background-color: $dropdown-item-hover-bg; color: $gl-text-color; + + // make sure the text color is not overriden + &.text-danger { + color: $brand-danger; + } } &.is-active { @@ -796,6 +817,164 @@ #{$selector}.dropdown-menu-align-right { margin-top: 2px; } + + .open { + #{$selector}.dropdown-menu, + #{$selector}.dropdown-menu-nav { + @media (max-width: $screen-xs-max) { + max-width: 100%; + } + } + } } @include new-style-dropdown('.js-namespace-select + '); + +header.navbar-gitlab-new .header-content .dropdown-menu.projects-dropdown-menu { + padding: 0; + + @media (max-width: $screen-xs-max) { + display: table; + left: -50px; + min-width: 300px; + } +} + +.projects-dropdown-container { + display: flex; + flex-direction: row; + width: 500px; + height: 334px; + + .project-dropdown-sidebar, + .project-dropdown-content { + padding: 8px 0; + } + + .loading-animation { + color: $almost-black; + } + + .project-dropdown-sidebar { + width: 30%; + border-right: 1px solid $border-color; + } + + .project-dropdown-content { + position: relative; + width: 70%; + } + + @media (max-width: $screen-xs-max) { + flex-direction: column; + width: 100%; + height: auto; + flex: 1; + + .project-dropdown-sidebar, + .project-dropdown-content { + width: 100%; + } + + .project-dropdown-sidebar { + border-bottom: 1px solid $border-color; + border-right: 0; + } + } +} + +.projects-dropdown-container { + .projects-list-frequent-container, + .projects-list-search-container, { + padding: 8px 0; + overflow-y: auto; + } + + .section-header, + .projects-list-frequent-container li.section-empty, + .projects-list-search-container li.section-empty { + padding: 0 15px; + } + + .section-header, + .projects-list-frequent-container li.section-empty, + .projects-list-search-container li.section-empty { + color: $gl-text-color-secondary; + font-size: $gl-font-size; + } + + .projects-list-frequent-container, + .projects-list-search-container { + li.section-empty.section-failure { + color: $callout-danger-color; + } + } + + .search-input-container { + position: relative; + padding: 4px $gl-padding; + + .search-icon { + position: absolute; + top: 13px; + right: 25px; + color: $md-area-border; + } + } + + .section-header { + font-weight: 700; + margin-top: 8px; + } + + .projects-list-search-container { + height: 284px; + } + + @media (max-width: $screen-xs-max) { + .projects-list-frequent-container { + width: auto; + height: auto; + padding-bottom: 0; + } + } +} + +.projects-list-item-container { + .project-item-avatar-container + .project-item-metadata-container { + float: left; + } + + .project-title, + .project-namespace { + max-width: 250px; + overflow: hidden; + text-overflow: ellipsis; + } + + &:hover { + .project-item-avatar-container .avatar { + border-color: $md-area-border; + } + } + + .project-title { + font-size: $gl-font-size; + font-weight: 400; + line-height: 16px; + } + + .project-namespace { + margin-top: 4px; + font-size: 12px; + line-height: 12px; + color: $gl-text-color-secondary; + } + + @media (max-width: $screen-xs-max) { + .project-item-metadata-container { + float: none; + } + } +} diff --git a/app/assets/stylesheets/framework/feature_highlight.scss b/app/assets/stylesheets/framework/feature_highlight.scss new file mode 100644 index 00000000000..ebae473df50 --- /dev/null +++ b/app/assets/stylesheets/framework/feature_highlight.scss @@ -0,0 +1,94 @@ +.feature-highlight { + position: relative; + margin-left: $gl-padding; + width: 20px; + height: 20px; + cursor: pointer; + + &::before { + content: ''; + display: block; + position: absolute; + top: 6px; + left: 6px; + width: 8px; + height: 8px; + background-color: $blue-500; + border-radius: 50%; + box-shadow: 0 0 0 rgba($blue-500, 0.4); + animation: pulse-highlight 2s infinite; + } + + &:hover::before, + &.disable-animation::before { + animation: none; + } + + &[disabled]::before { + display: none; + } +} + +.is-showing-fly-out { + .feature-highlight { + display: none; + } +} + +.feature-highlight-popover-content { + display: none; + + hr { + margin: $gl-padding * 0.5 0; + } + + .btn-link { + @include btn-svg; + + svg path { + fill: currentColor; + } + } + + .dismiss-feature-highlight { + padding: 0; + } + + svg:first-child { + width: 100%; + background-color: $indigo-50; + border-top-left-radius: 2px; + border-top-right-radius: 2px; + border-bottom: 1px solid darken($gray-normal, 8%); + } +} + +.popover .feature-highlight-popover-content { + display: block; +} + +.feature-highlight-popover { + padding: 0; + + .popover-content { + padding: 0; + } +} + +.feature-highlight-popover-sub-content { + padding: 9px 14px; +} + +@include keyframes(pulse-highlight) { + 0% { + box-shadow: 0 0 0 0 rgba($blue-200, 0.4); + } + + 70% { + box-shadow: 0 0 0 10px transparent; + } + + 100% { + box-shadow: 0 0 0 0 transparent; + } +} diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index a39927eb0df..6c14e8b97e0 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -267,14 +267,26 @@ // TODO: change global style .ajax-project-dropdown, +.ajax-users-dropdown, +body[data-page="projects:edit"] #select2-drop, body[data-page="projects:new"] #select2-drop, +body[data-page="projects:merge_requests:edit"] #select2-drop, body[data-page="projects:blob:new"] #select2-drop, body[data-page="profiles:show"] #select2-drop, +body[data-page="admin:groups:show"] #select2-drop, +body[data-page="projects:issues:show"] #select2-drop, body[data-page="projects:blob:edit"] #select2-drop { &.select2-drop { + border: 1px solid $dropdown-border-color; + border-radius: $border-radius-base; color: $gl-text-color; } + &.select2-drop-above { + border-top: none; + margin-top: -4px; + } + .select2-results { .select2-no-results, .select2-searching, diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 40e8a928e6e..ef58382ba41 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -132,3 +132,7 @@ width: calc(100% + 35px); } } + +.issuable-sidebar { + @include new-style-dropdown; +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 26920869bec..01fffa717e9 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -591,9 +591,10 @@ $ui-dev-kit-example-border: #ddd; /* Pipeline Graph */ -$stage-hover-bg: #eaf3fc; -$stage-hover-border: #d1e7fc; -$action-icon-color: #d6d6d6; +$stage-hover-bg: $gray-darker; +$ci-action-icon-size: 22px; +$pipeline-dropdown-line-height: 20px; +$pipeline-dropdown-status-icon-size: 18px; /* Pipeline Schedules diff --git a/app/assets/stylesheets/new_nav.scss b/app/assets/stylesheets/new_nav.scss index 54fa4109f8b..b711bd12c73 100644 --- a/app/assets/stylesheets/new_nav.scss +++ b/app/assets/stylesheets/new_nav.scss @@ -8,15 +8,23 @@ header.navbar-gitlab-new { border-bottom: 0; .header-content { + display: -webkit-flex; + display: flex; padding-left: 0; .title-container { + display: -webkit-flex; + display: flex; + -webkit-align-items: stretch; align-items: stretch; + -webkit-flex: 1 1 auto; + flex: 1 1 auto; padding-top: 0; overflow: visible; } .title { + display: -webkit-flex; display: flex; padding-right: 0; color: currentColor; @@ -27,6 +35,7 @@ header.navbar-gitlab-new { } > a { + display: -webkit-flex; display: flex; align-items: center; padding-right: $gl-padding; @@ -177,6 +186,7 @@ header.navbar-gitlab-new { } .navbar-sub-nav { + display: -webkit-flex; display: flex; margin-bottom: 0; color: $indigo-200; diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 3d04df8d820..50ec5110bf1 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -322,14 +322,13 @@ } .build-dropdown { - padding: $gl-padding 0; + @include new-style-dropdown; - .dropdown-menu-toggle { - margin-top: 8px; - } + margin: $gl-padding 0; + padding: 0; - .dropdown-menu { - margin-top: -$gl-padding; + .dropdown-menu-toggle { + margin-top: #{$gl-padding / 2}; } svg { diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index a8d2ae0af28..a52ac0d53e7 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -12,6 +12,8 @@ .environments-container { .ci-table { + @include new-style-dropdown; + .deployment-column { > span { word-break: break-all; @@ -167,7 +169,7 @@ } .metric-area { - opacity: 0.8; + opacity: 0.25; } .prometheus-graph-overlay { @@ -249,8 +251,14 @@ font-weight: $gl-font-weight-bold; } - .label-axis-text, - .text-metric-usage { + .label-axis-text { + fill: $black; + font-weight: $gl-font-weight-normal; + font-size: 10px; + } + + .text-metric-usage, + .legend-metric-title { fill: $black; font-weight: $gl-font-weight-normal; font-size: 12px; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index ab5a901da71..9f2cb979518 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -473,7 +473,7 @@ padding-top: 6px; } - .open .dropdown-menu { + .dropdown-menu { width: 100%; } } @@ -486,6 +486,24 @@ } } +.sidebar-move-issue-dropdown { + @include new-style-dropdown; +} + +.sidebar-move-issue-confirmation-button { + width: 100%; + + &.is-loading { + .sidebar-move-issue-confirmation-loading-icon { + display: inline-block; + } + } +} + +.sidebar-move-issue-confirmation-loading-icon { + display: none; +} + .detail-page-description { padding: 16px 0; @@ -498,6 +516,7 @@ color: $gray-darkest; display: block; margin: 16px 0 0; + font-size: 85%; .author_link { color: $gray-darkest; @@ -598,6 +617,8 @@ } .issuable-actions { + @include new-style-dropdown; + padding-top: 10px; @media (min-width: $screen-sm-min) { diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index e2177f96aee..e8ca5cedaee 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -143,8 +143,12 @@ ul.related-merge-requests > li { } } -.issue-form .select2-container { - width: 250px !important; +.issue-form { + @include new-style-dropdown; + + .select2-container { + width: 250px !important; + } } .issues-footer { @@ -186,6 +190,8 @@ ul.related-merge-requests > li { } .create-mr-dropdown-wrap { + @include new-style-dropdown; + .btn-group:not(.hide) { display: flex; } @@ -212,15 +218,6 @@ ul.related-merge-requests > li { } li:not(.divider) { - padding: 6px; - cursor: pointer; - - &:hover, - &:focus { - background-color: $dropdown-hover-color; - color: $white-light; - } - &.droplab-item-selected { .icon-container { i { @@ -250,6 +247,10 @@ ul.related-merge-requests > li { } } +.discussion-reply-holder .note-edit-form { + display: block; +} + @media (min-width: $screen-sm-min) { .emoji-block .row { display: flex; diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index ee48f7a3626..443f5500684 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -116,6 +116,8 @@ } .manage-labels-list { + @include new-style-dropdown; + > li:not(.empty-message):not(.is-not-draggable) { background-color: $white-light; cursor: move; diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 3fb02e9964f..b3bab082a35 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -55,6 +55,10 @@ display: -webkit-flex; display: flex; } + + .dropdown-menu.dropdown-menu-align-right { + margin-top: -2px; + } } .form-horizontal { @@ -306,3 +310,7 @@ } } } + +.member-form-control { + @include new-style-dropdown; +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 9d51c0b7a8a..8609f72bdab 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -174,17 +174,6 @@ vertical-align: top; } - .mini-pipeline-graph-dropdown-menu .mini-pipeline-graph-dropdown-item { - display: flex; - align-items: center; - - .ci-status-text, - .ci-status-icon { - top: 0; - margin-right: 10px; - } - } - .normal { line-height: 28px; } @@ -291,6 +280,7 @@ .dropdown-toggle { .fa { + margin-left: 0; color: inherit; } } @@ -731,3 +721,7 @@ font-size: 16px; } } + +.merge-request-form { + @include new-style-dropdown; +} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 9558924bbcb..5d7c85b16ef 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -20,13 +20,11 @@ } } -.new-note { - display: none; -} - .new-note, .note-edit-form { .note-form-actions { + @include new-style-dropdown; + position: relative; margin: $gl-padding 0 0; } @@ -202,6 +200,10 @@ .discussion-reply-holder { background-color: $white-light; padding: 10px 16px; + + &.is-replying { + padding-bottom: $gl-padding; + } } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index fbfe5d3c682..45f2aed1531 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -100,6 +100,20 @@ ul.notes { } } + .editing-spinner { + display: none; + } + + &.is-requesting { + .note-timestamp { + display: none; + } + + .editing-spinner { + display: inline-block; + } + } + &.is-editing { .note-header, .note-text, @@ -365,9 +379,7 @@ ul.notes { } .discussion-header, -.note-header { - position: relative; - +.note-header-info { a { color: inherit; @@ -402,6 +414,10 @@ ul.notes { .note-header-info { min-width: 0; padding-bottom: 8px; + + &.discussion { + padding-bottom: 0; + } } .system-note .note-header-info { @@ -453,6 +469,8 @@ ul.notes { } .note-actions { + @include new-style-dropdown; + align-self: flex-start; flex-shrink: 0; display: inline-flex; @@ -488,22 +506,6 @@ ul.notes { .more-actions-dropdown { width: 180px; min-width: 180px; - margin-top: $gl-btn-padding; - - li > a, - li > .btn { - color: $gl-text-color; - padding: $gl-btn-padding; - width: 100%; - text-align: left; - - &:hover, - &:focus { - color: $gl-text-color; - background-color: $blue-25; - border-radius: $border-radius-default; - } - } } .discussion-actions { @@ -814,10 +816,6 @@ ul.notes { } } -.discussion-notes .flash-container { - margin-bottom: 0; -} - // Merge request notes in diffs .diff-file { // Diff is inline diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 51656669c98..cb8815e4775 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -40,7 +40,7 @@ .btn.btn-retry:hover, .btn.btn-retry:focus { - border-color: $gray-darkest; + border-color: $dropdown-toggle-active-border-color; background-color: $white-normal; } @@ -206,8 +206,8 @@ .stage-cell { .mini-pipeline-graph-dropdown-toggle svg { - height: 22px; - width: 22px; + height: $ci-action-icon-size; + width: $ci-action-icon-size; position: absolute; top: -1px; left: -1px; @@ -219,7 +219,7 @@ display: inline-block; position: relative; vertical-align: middle; - height: 22px; + height: $ci-action-icon-size; margin: 3px 0; + .stage-container { @@ -257,6 +257,8 @@ // Pipeline visualization .pipeline-actions { + @include new-style-dropdown; + border-bottom: none; } @@ -308,7 +310,7 @@ a { text-decoration: none; - color: $gl-text-color-secondary; + color: $gl-text-color; } svg { @@ -432,7 +434,11 @@ width: 186px; margin-bottom: 10px; white-space: normal; - color: $gl-text-color-secondary; + + // ensure .build-content has hover style when action-icon is hovered + .ci-job-dropdown-container:hover .build-content { + @extend .build-content:hover; + } // Action Icons in big pipeline-graph nodes .ci-action-icon-container .ci-action-icon-wrapper { @@ -445,11 +451,11 @@ &:hover { background-color: $stage-hover-bg; - border: 1px solid $stage-hover-bg; + border: 1px solid $dropdown-toggle-active-border-color; } svg { - fill: $border-color; + fill: $gl-text-color-secondary; position: relative; left: -1px; top: -1px; @@ -475,19 +481,10 @@ background-color: transparent; border: none; padding: 0; - color: $gl-text-color-secondary; &:focus { outline: none; } - - &:hover { - color: $gl-text-color; - - .dropdown-counter-badge { - color: $gl-text-color; - } - } } .build-content { @@ -502,8 +499,7 @@ a.build-content:hover, button.build-content:hover { background-color: $stage-hover-bg; - border: 1px solid $stage-hover-border; - color: $gl-text-color; + border: 1px solid $dropdown-toggle-active-border-color; } @@ -564,7 +560,6 @@ // Triggers the dropdown in the big pipeline graph .dropdown-counter-badge { - color: $border-color; font-weight: 100; font-size: 15px; position: absolute; @@ -606,8 +601,8 @@ button.mini-pipeline-graph-dropdown-toggle { background-color: $white-light; border-width: 1px; border-style: solid; - width: 22px; - height: 22px; + width: $ci-action-icon-size; + height: $ci-action-icon-size; margin: 0; padding: 0; transition: all 0.2s linear; @@ -669,105 +664,119 @@ button.mini-pipeline-graph-dropdown-toggle { } } +@include new-style-dropdown('.big-pipeline-graph-dropdown-menu'); +@include new-style-dropdown('.mini-pipeline-graph-dropdown-menu'); + // dropdown content for big and mini pipeline .big-pipeline-graph-dropdown-menu, .mini-pipeline-graph-dropdown-menu { width: 195px; max-width: 195px; - li { - padding: 2px 3px; - } - .scrollable-menu { padding: 0; max-height: 245px; overflow: auto; } - // Action icon on the right - a.ci-action-icon-wrapper { - color: $action-icon-color; - border: 1px solid $action-icon-color; - border-radius: 20px; - width: 22px; - height: 22px; - padding: 2px 0 0 5px; - cursor: pointer; - float: right; - margin: -26px 9px 0 0; - font-size: 12px; - background-color: $white-light; + li { + position: relative; - &:hover, - &:focus { - background-color: $stage-hover-bg; - border: 1px solid transparent; + // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered + &:hover > .mini-pipeline-graph-dropdown-item, + &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item { + @extend .mini-pipeline-graph-dropdown-item:hover; } - svg { - width: 22px; - height: 22px; - left: -6px; - position: relative; - top: -3px; - fill: $action-icon-color; - } + // Action icon on the right + a.ci-action-icon-wrapper { + border-radius: 50%; + border: 1px solid $border-color; + width: $ci-action-icon-size; + height: $ci-action-icon-size; + padding: 2px 0 0 5px; + font-size: 12px; + background-color: $white-light; + position: absolute; + top: 50%; + right: $gl-padding; + margin-top: -#{$ci-action-icon-size / 2}; - &:hover svg, - &:focus svg { - fill: $gl-text-color; - } - } + &:hover, + &:focus { + background-color: $stage-hover-bg; + border: 1px solid $dropdown-toggle-active-border-color; + } - // link to the build - .mini-pipeline-graph-dropdown-item { - padding: 3px 7px 4px; - clear: both; - font-weight: $gl-font-weight-normal; - line-height: 1.428571429; - white-space: nowrap; - margin: 0 5px; - border-radius: 3px; + svg { + fill: $gl-text-color-secondary; + width: $ci-action-icon-size; + height: $ci-action-icon-size; + left: -6px; + position: relative; + top: -3px; + } - // build name - .ci-build-text, - .ci-status-text { - font-weight: 200; - overflow: hidden; + &:hover svg, + &:focus svg { + fill: $gl-text-color; + } + } + + // link to the build + .mini-pipeline-graph-dropdown-item { + padding: 3px 7px 4px; + align-items: center; + clear: both; + display: flex; + font-weight: normal; + line-height: $line-height-base; white-space: nowrap; - text-overflow: ellipsis; - max-width: 70%; - color: $gl-text-color-secondary; - margin-left: 2px; - display: inline-block; - top: 1px; - vertical-align: text-bottom; - position: relative; + border-radius: 3px; - @media (max-width: $screen-xs-max) { - max-width: 60%; + .ci-job-name-component { + align-items: center; + display: flex; + flex: 1; } - } - // status icon on the left - .ci-status-icon { - top: 3px; - position: relative; + // build name + .ci-build-text, + .ci-status-text { + font-weight: 200; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 70%; + margin-left: 2px; + display: inline-block; - > svg { - overflow: visible; - width: 18px; - height: 18px; + @media (max-width: $screen-xs-max) { + max-width: 60%; + } } - } - &:hover, - &:focus { - outline: none; - text-decoration: none; - color: $gl-text-color; - background-color: $stage-hover-bg; + .ci-status-icon { + @extend .append-right-8; + + position: relative; + + > svg { + width: $pipeline-dropdown-status-icon-size; + height: $pipeline-dropdown-status-icon-size; + margin: 3px 0; + position: relative; + overflow: visible; + display: block; + } + } + + &:hover, + &:focus { + outline: none; + text-decoration: none; + background-color: $stage-hover-bg; + } } } } @@ -776,16 +785,9 @@ button.mini-pipeline-graph-dropdown-toggle { .big-pipeline-graph-dropdown-menu { width: 195px; min-width: 195px; - left: auto; - right: -195px; - top: -4px; + left: 100%; + top: -10px; box-shadow: 0 1px 5px $black-transparent; - - .mini-pipeline-graph-dropdown-item { - .ci-status-icon { - top: -1px; - } - } } /** @@ -806,15 +808,14 @@ button.mini-pipeline-graph-dropdown-toggle { } &::before { - left: -5px; - margin-top: -6px; + left: -6px; + margin-top: 3px; border-width: 7px 5px 7px 0; border-right-color: $border-color; } &::after { - left: -4px; - margin-top: -9px; + left: -5px; border-width: 10px 7px 10px 0; border-right-color: $white-light; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 19caefa1961..dd600a27545 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -800,8 +800,10 @@ pre.light-well { } } -.new_protected_branch, +.new-protected-branch, .new-protected-tag { + @include new-style-dropdown; + label { margin-top: 6px; font-weight: $gl-font-weight-normal; @@ -821,19 +823,9 @@ pre.light-well { .protected-branches-list, .protected-tags-list { - margin-bottom: 30px; - - a { - color: $gl-text-color; - - &:hover { - color: $gl-link-color; - } + @include new-style-dropdown; - &.is-active { - font-weight: $gl-font-weight-bold; - } - } + margin-bottom: 30px; .settings-message { margin: 0; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1d92ea11bda..97922e39ba8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -202,7 +202,7 @@ class ApplicationController < ActionController::Base end def check_password_expiration - if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && current_user.allow_password_authentication? + if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && !current_user.ldap_user? return redirect_to new_profile_password_path end end diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 59be955599d..dfc8bd0ba81 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -41,12 +41,6 @@ class AutocompleteController < ApplicationController project = Project.find_by_id(params[:project_id]) projects = projects_finder.execute(project, search: params[:search], offset_id: params[:offset_id]) - no_project = { - id: 0, - name_with_namespace: 'No project' - } - projects.unshift(no_project) unless params[:offset_id].present? - render json: projects.to_json(only: [:id, :name_with_namespace], methods: :name_with_namespace) end diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index a34a82b7ba6..23909bd2d39 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -36,6 +36,34 @@ module IssuableCollections @merge_requests_finder ||= issuable_finder_for(MergeRequestsFinder) end + def redirect_out_of_range(relation, total_pages) + return false if total_pages.zero? + + out_of_range = relation.current_page > total_pages + + if out_of_range + redirect_to(url_for(params.merge(page: total_pages, only_path: true))) + end + + out_of_range + end + + def issues_page_count(relation) + page_count_for_relation(relation, issues_finder.row_count) + end + + def merge_requests_page_count(relation) + page_count_for_relation(relation, merge_requests_finder.row_count) + end + + def page_count_for_relation(relation, row_count) + limit = relation.limit_value.to_f + + return 1 if limit.zero? + + (row_count.to_f / limit).ceil + end + def issuable_finder_for(finder_class) finder_class.new(current_user, filter_params) end diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index af5f683bab5..18fd8eb114d 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -3,6 +3,7 @@ module NotesActions extend ActiveSupport::Concern included do + before_action :set_polling_interval_header, only: [:index] before_action :authorize_admin_note!, only: [:update, :destroy] before_action :note_project, only: [:create] end @@ -12,14 +13,18 @@ module NotesActions notes_json = { notes: [], last_fetched_at: current_fetched_at } - @notes = notes_finder.execute.inc_relations_for_view - @notes = prepare_notes_for_rendering(@notes) + notes = notes_finder.execute + .inc_relations_for_view + .reject { |n| n.cross_reference_not_visible_for?(current_user) } - @notes.each do |note| - next if note.cross_reference_not_visible_for?(current_user) + notes = prepare_notes_for_rendering(notes) - notes_json[:notes] << note_json(note) - end + notes_json[:notes] = + if noteable.discussions_rendered_on_frontend? + note_serializer.represent(notes) + else + notes.map { |note| note_json(note) } + end render json: notes_json end @@ -82,22 +87,27 @@ module NotesActions } if note.persisted? - attrs.merge!( - valid: true, - id: note.id, - discussion_id: note.discussion_id(noteable), - html: note_html(note), - note: note.note - ) + attrs[:valid] = true - discussion = note.to_discussion(noteable) - unless discussion.individual_note? + if noteable.nil? || noteable.discussions_rendered_on_frontend? + attrs.merge!(note_serializer.represent(note)) + else attrs.merge!( - discussion_resolvable: discussion.resolvable?, - - diff_discussion_html: diff_discussion_html(discussion), - discussion_html: discussion_html(discussion) + id: note.id, + discussion_id: note.discussion_id(noteable), + html: note_html(note), + note: note.note ) + + discussion = note.to_discussion(noteable) + unless discussion.individual_note? + attrs.merge!( + discussion_resolvable: discussion.resolvable?, + + diff_discussion_html: diff_discussion_html(discussion), + discussion_html: discussion_html(discussion) + ) + end end else attrs.merge!( @@ -168,6 +178,10 @@ module NotesActions ) end + def set_polling_interval_header + Gitlab::PollingInterval.set_header(response, interval: 6_000) + end + def noteable @noteable ||= notes_finder.target end @@ -180,6 +194,10 @@ module NotesActions @notes_finder ||= NotesFinder.new(project, current_user, finder_params) end + def note_serializer + NoteSerializer.new(project: project, noteable: noteable, current_user: current_user) + end + def note_project return @note_project if defined?(@note_project) return nil unless project diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index aa8cf630032..fda944adecd 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -1,8 +1,6 @@ class PasswordsController < Devise::PasswordsController - include Gitlab::CurrentSettings - before_action :resource_from_email, only: [:create] - before_action :check_password_authentication_available, only: [:create] + before_action :prevent_ldap_reset, only: [:create] before_action :throttle_reset, only: [:create] def edit @@ -40,11 +38,11 @@ class PasswordsController < Devise::PasswordsController self.resource = resource_class.find_by_email(email) end - def check_password_authentication_available - return if current_application_settings.password_authentication_enabled? && (resource.nil? || resource.allow_password_authentication?) + def prevent_ldap_reset + return unless resource&.ldap_user? redirect_to after_sending_reset_password_instructions_path_for(resource_name), - alert: "Password authentication is unavailable." + alert: "Cannot reset password for LDAP user." end def throttle_reset diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb index c423761ab24..7beb52dd8e8 100644 --- a/app/controllers/profiles/passwords_controller.rb +++ b/app/controllers/profiles/passwords_controller.rb @@ -77,7 +77,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController end def authorize_change_password! - render_404 unless @user.allow_password_authentication? + render_404 if @user.ldap_user? end def user_params diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 1afaceac567..dc9e6f71152 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -15,7 +15,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :authorize_create_issue!, only: [:new, :create] # Allow modify issue - before_action :authorize_update_issue!, only: [:edit, :update] + before_action :authorize_update_issue!, only: [:edit, :update, :move] # Allow create a new branch and empty WIP merge request from current issue before_action :authorize_create_merge_request!, only: [:create_merge_request] @@ -27,10 +27,9 @@ class Projects::IssuesController < Projects::ApplicationController @issues = issues_collection @issues = @issues.page(params[:page]) @issuable_meta_data = issuable_meta_data(@issues, @collection_type) + @total_pages = issues_page_count(@issues) - if @issues.out_of_range? && @issues.total_pages != 0 - return redirect_to url_for(params.merge(page: @issues.total_pages, only_path: true)) - end + return if redirect_out_of_range(@issues, @total_pages) if params[:label_name].present? @labels = LabelsFinder.new(current_user, project_id: @project.id, title: params[:label_name]).execute @@ -91,11 +90,25 @@ class Projects::IssuesController < Projects::ApplicationController respond_to do |format| format.html format.json do - render json: IssueSerializer.new.represent(@issue) + render json: serializer.represent(@issue) end end end + def discussions + notes = @issue.notes + .inc_relations_for_view + .includes(:noteable) + .fresh + .reject { |n| n.cross_reference_not_visible_for?(current_user) } + + prepare_notes_for_rendering(notes) + + discussions = Discussion.build_collection(notes, @issue) + + render json: DiscussionSerializer.new(project: @project, noteable: @issue, current_user: current_user).represent(discussions) + end + def create create_params = issue_params.merge(spammable_params).merge( merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], @@ -128,25 +141,33 @@ class Projects::IssuesController < Projects::ApplicationController @issue = Issues::UpdateService.new(project, current_user, update_params).execute(issue) + respond_to do |format| + format.html do + recaptcha_check_with_fallback { render :edit } + end + + format.json do + render_issue_json + end + end + + rescue ActiveRecord::StaleObjectError + render_conflict_response + end + + def move + params.require(:move_to_project_id) + if params[:move_to_project_id].to_i > 0 new_project = Project.find(params[:move_to_project_id]) return render_404 unless issue.can_move?(current_user, new_project) - move_service = Issues::MoveService.new(project, current_user) - @issue = move_service.execute(@issue, new_project) + @issue = Issues::UpdateService.new(project, current_user, target_project: new_project).execute(issue) end respond_to do |format| - format.html do - recaptcha_check_with_fallback { render :edit } - end - format.json do - if @issue.valid? - render json: IssueSerializer.new.represent(@issue) - else - render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity - end + render_issue_json end end @@ -257,6 +278,14 @@ class Projects::IssuesController < Projects::ApplicationController return render_404 unless @project.feature_available?(:issues, current_user) end + def render_issue_json + if @issue.valid? + render json: serializer.represent(@issue) + else + render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity + end + end + def issue_params params.require(:issue).permit(*issue_params_attributes) end @@ -287,4 +316,8 @@ class Projects::IssuesController < Projects::ApplicationController redirect_to new_user_session_path, notice: notice end + + def serializer + IssueSerializer.new(current_user: current_user, project: issue.project) + end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index e3fa3736808..5095d7fd445 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -18,10 +18,9 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @merge_requests = @merge_requests.page(params[:page]) @merge_requests = @merge_requests.preload(merge_request_diff: :merge_request) @issuable_meta_data = issuable_meta_data(@merge_requests, @collection_type) + @total_pages = merge_requests_page_count(@merge_requests) - if @merge_requests.out_of_range? && @merge_requests.total_pages != 0 - return redirect_to url_for(params.merge(page: @merge_requests.total_pages, only_path: true)) - end + return if redirect_out_of_range(@merge_requests, @total_pages) if params[:label_name].present? labels_params = { project_id: @project.id, title: params[:label_name] } diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 7e0d3b5c979..9848497f258 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -24,7 +24,6 @@ class IssuableFinder include CreatedAtFilter NONE = '0'.freeze - IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page state].freeze attr_accessor :current_user, :params @@ -62,13 +61,17 @@ class IssuableFinder execute.find_by(*params) end + def row_count + Gitlab::IssuablesCountForState.new(self).for_state_or_opened(params[:state]) + end + # We often get counts for each state by running a query per state, and # counting those results. This is typically slower than running one query # (even if that query is slower than any of the individual state queries) and # grouping and counting within that query. # def count_by_state - count_params = params.merge(state: nil, sort: nil, for_counting: true) + count_params = params.merge(state: nil, sort: nil) labels_count = label_names.any? ? label_names.count : 1 finder = self.class.new(current_user, count_params) counts = Hash.new(0) @@ -91,16 +94,6 @@ class IssuableFinder execute.find_by!(*params) end - def state_counter_cache_key - cache_key(state_counter_cache_key_components) - end - - def clear_caches! - state_counter_cache_key_components_permutations.each do |components| - Rails.cache.delete(cache_key(components)) - end - end - def group return @group if defined?(@group) @@ -432,20 +425,4 @@ class IssuableFinder def current_user_related? params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me' end - - def state_counter_cache_key_components - opts = params.with_indifferent_access - opts.except!(*IRRELEVANT_PARAMS_FOR_CACHE_KEY) - opts.delete_if { |_, value| value.blank? } - - ['issuables_count', klass.to_ability_name, opts.sort] - end - - def state_counter_cache_key_components_permutations - [state_counter_cache_key_components] - end - - def cache_key(components) - Digest::SHA1.hexdigest(components.flatten.join('-')) - end end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 0ec42a4e6eb..d2275139c42 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -14,6 +14,7 @@ # search: string # label_name: string # sort: string +# my_reaction_emoji: string # class IssuesFinder < IssuableFinder CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER @@ -54,44 +55,10 @@ class IssuesFinder < IssuableFinder project.team.max_member_access(current_user.id) >= CONFIDENTIAL_ACCESS_LEVEL end - # Anonymous users can't see any confidential issues. - # - # Users without access to see _all_ confidential issues (as in - # `user_can_see_all_confidential_issues?`) are more complicated, because they - # can see confidential issues where: - # 1. They are an assignee. - # 2. They are an author. - # - # That's fine for most cases, but if we're just counting, we need to cache - # effectively. If we cached this accurately, we'd have a cache key for every - # authenticated user without sufficient access to the project. Instead, when - # we are counting, we treat them as if they can't see any confidential issues. - # - # This does mean the counts may be wrong for those users, but avoids an - # explosion in cache keys. - def user_cannot_see_confidential_issues?(for_counting: false) + def user_cannot_see_confidential_issues? return false if user_can_see_all_confidential_issues? - current_user.blank? || for_counting || params[:for_counting] - end - - def state_counter_cache_key_components - extra_components = [ - user_can_see_all_confidential_issues?, - user_cannot_see_confidential_issues?(for_counting: true) - ] - - super + extra_components - end - - def state_counter_cache_key_components_permutations - # Ignore the last two, as we'll provide both options for them. - components = super.first[0..-3] - - [ - components + [false, true], - components + [true, false] - ] + current_user.blank? end def by_assignee(items) diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 771da3d441d..d0687d28c21 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -16,6 +16,7 @@ # label_name: string # sort: string # non_archived: boolean +# my_reaction_emoji: string # class MergeRequestsFinder < IssuableFinder def klass diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 36bb7015fa1..017df8f6794 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -303,7 +303,7 @@ module ApplicationHelper end def show_new_nav? - cookies["new_nav"] == "true" + true end def collapsed_sidebar? diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 04955ed625e..b93f5f0af1c 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -84,6 +84,18 @@ module ApplicationSettingsHelper end end + def key_restriction_options_for_select(type) + bit_size_options = Gitlab::SSHPublicKey.supported_sizes(type).map do |bits| + ["Must be at least #{bits} bits", bits] + end + + [ + ['Are allowed', 0], + *bit_size_options, + ['Are forbidden', ApplicationSetting::FORBIDDEN_KEY_VALUE] + ] + end + def repository_storages_options_for_select options = Gitlab.config.repositories.storages.map do |name, storage| ["#{name} - #{storage['path']}", name] @@ -116,6 +128,9 @@ module ApplicationSettingsHelper :domain_blacklist_enabled, :domain_blacklist_raw, :domain_whitelist_raw, + :dsa_key_restriction, + :ecdsa_key_restriction, + :ed25519_key_restriction, :email_author_in_body, :enabled_git_access_protocol, :gravatar_enabled, @@ -159,6 +174,7 @@ module ApplicationSettingsHelper :repository_storages, :require_two_factor_authentication, :restricted_visibility_levels, + :rsa_key_restriction, :send_user_confirmation_email, :sentry_dsn, :sentry_enabled, diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index ff305fa39b4..5089da519df 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -97,9 +97,11 @@ module DropdownsHelper end end - def dropdown_footer(&block) + def dropdown_footer(add_content_class: false, &block) content_tag(:div, class: "dropdown-footer") do - if block + if add_content_class + content_tag(:div, capture(&block), class: "dropdown-footer-content") + else capture(&block) end end diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 9247b1f72de..b5dece38de1 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -1,9 +1,9 @@ module FormHelper - def form_errors(model) + def form_errors(model, type: 'form') return unless model.errors.any? pluralized = 'error'.pluralize(model.errors.count) - headline = "The form contains the following #{pluralized}:" + headline = "The #{type} contains the following #{pluralized}:" content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do content_tag(:h4, headline) << diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 4123a96911f..dd159d12aa0 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -68,7 +68,7 @@ module GroupsHelper def group_title_link(group, hidable: false) link_to(group_path(group), class: "group-path #{'hidable' if hidable}") do output = - if show_new_nav? + if show_new_nav? && !Rails.env.test? image_tag(group_icon(group), class: "avatar-tile", width: 16, height: 16) else "" diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 2a748ce0a75..717abf2082d 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -35,7 +35,7 @@ module IssuablesHelper def serialize_issuable(issuable) case issuable when Issue - IssueSerializer.new.represent(issuable).to_json + IssueSerializer.new(current_user: current_user, project: issuable.project).represent(issuable).to_json when MergeRequest MergeRequestSerializer .new(current_user: current_user, project: issuable.project) @@ -207,12 +207,10 @@ module IssuablesHelper endpoint: project_issue_path(@project, issuable), canUpdate: can?(current_user, :update_issue, issuable), canDestroy: can?(current_user, :destroy_issue, issuable), - canMove: current_user ? issuable.can_move?(current_user) : false, issuableRef: issuable.to_reference, isConfidential: issuable.confidential, - markdownPreviewUrl: preview_markdown_path(@project), - markdownDocs: help_page_path('user/markdown'), - projectsAutocompleteUrl: autocomplete_projects_path(project_id: @project.id), + markdownPreviewPath: preview_markdown_path(@project), + markdownDocsPath: help_page_path('user/markdown'), issuableTemplates: issuable_templates(issuable), projectPath: ref_project.path, projectNamespace: ref_project.namespace.full_path, @@ -240,16 +238,10 @@ module IssuablesHelper } end - def issuables_count_for_state(issuable_type, state, finder: nil) - finder ||= public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend - cache_key = finder.state_counter_cache_key + def issuables_count_for_state(issuable_type, state) + finder = public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend - @counts ||= {} - @counts[cache_key] ||= Rails.cache.fetch(cache_key, expires_in: 2.minutes) do - finder.count_by_state - end - - @counts[cache_key][state] + Gitlab::IssuablesCountForState.new(finder)[state] end def close_issuable_url(issuable) @@ -305,14 +297,6 @@ module IssuablesHelper cookies[:collapsed_gutter] == 'true' end - def issuable_state_scope(issuable) - if issuable.respond_to?(:merged?) && issuable.merged? - :merged - else - issuable.open? ? :opened : :closed - end - end - def issuable_templates(issuable) @issuable_templates ||= case issuable @@ -361,6 +345,8 @@ module IssuablesHelper def issuable_sidebar_options(issuable, can_edit_issuable) { endpoint: "#{issuable_json_path(issuable)}?basic=true", + moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable), + projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id), editable: can_edit_issuable, currentUser: current_user.as_json(only: [:username, :id, :name], methods: :avatar_url), rootPath: root_path, diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 7e1ccb23e9e..3d0fdce6a43 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -47,13 +47,6 @@ module IssuesHelper end end - def bulk_update_milestone_options - milestones = @project.milestones.active.reorder(due_date: :asc, title: :asc).to_a - milestones.unshift(Milestone::None) - - options_from_collection_for_select(milestones, 'id', 'title', params[:milestone_id]) - end - def milestone_options(object) milestones = object.project.milestones.active.reorder(due_date: :asc, title: :asc).to_a milestones.unshift(object.milestone) if object.milestone.present? && object.milestone.closed? @@ -93,14 +86,6 @@ module IssuesHelper return 'hidden' if issue.closed? == closed end - def merge_requests_sentence(merge_requests) - # Sorting based on the `!123` or `group/project!123` reference will sort - # local merge requests first. - merge_requests.map do |merge_request| - merge_request.to_reference(@project) - end.sort.to_sentence(last_word_connector: ', or ') - end - def confidential_icon(issue) icon('eye-slash') if issue.confidential? end @@ -137,7 +122,7 @@ module IssuesHelper end def awards_sort(awards) - awards.sort_by do |award, notes| + awards.sort_by do |award, award_emojis| if award == "thumbsup" 0 elsif award == "thumbsdown" @@ -148,18 +133,6 @@ module IssuesHelper end.to_h end - def due_date_options - options = [ - Issue::AnyDueDate, - Issue::NoDueDate, - Issue::DueThisWeek, - Issue::DueThisMonth, - Issue::Overdue - ] - - options_from_collection_for_select(options, 'name', 'title', params[:due_date]) - end - def link_to_discussions_to_resolve(merge_request, single_discussion = nil) link_text = merge_request.to_reference link_text += " (discussion #{single_discussion.first_note.id})" if single_discussion diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index e857e837c16..8c5e258f519 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -93,11 +93,13 @@ module NotesHelper end end - def notes_url + def notes_url(params = {}) if @snippet.is_a?(PersonalSnippet) - snippet_notes_path(@snippet) + snippet_notes_path(@snippet, params) else - project_noteable_notes_path(@project, target_id: @noteable.id, target_type: @noteable.class.name.underscore) + params.merge!(target_id: @noteable.id, target_type: @noteable.class.name.underscore) + + project_noteable_notes_path(@project, params) end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c5490a2d1a8..02fe82ea872 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -62,7 +62,7 @@ module ProjectsHelper project_link = link_to project_path(project), { class: "project-item-select-holder" } do output = - if show_new_nav? + if show_new_nav? && !Rails.env.test? project_icon(project, alt: project.name, class: 'avatar-tile', width: 16, height: 16) else "" @@ -72,12 +72,6 @@ module ProjectsHelper output.html_safe end - if current_user - project_link << button_tag(type: 'button', class: 'dropdown-toggle-caret js-projects-dropdown-toggle', aria: { label: 'Toggle switch project dropdown' }, data: { target: '.js-dropdown-menu-projects', toggle: 'dropdown', order_by: 'last_activity_at' }) do - icon("chevron-down") - end - end - "#{namespace_link} / #{project_link}".html_safe end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index 08fd97cd048..c98f65c7644 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -22,8 +22,14 @@ module SystemNoteHelper 'duplicate' => 'icon_clone' }.freeze + def system_note_icon_name(note) + ICON_NAMES_BY_ACTION[note.system_note_metadata&.action] + end + def icon_for_system_note(note) - icon_name = ICON_NAMES_BY_ACTION[note.system_note_metadata&.action] + icon_name = system_note_icon_name(note) custom_icon(icon_name) if icon_name end + + extend self end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 8e446ff6dd8..3568e72e463 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -13,6 +13,11 @@ class ApplicationSetting < ActiveRecord::Base [\r\n] # any number of newline characters }x + # Setting a key restriction to `-1` means that all keys of this type are + # forbidden. + FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN + SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze + serialize :restricted_visibility_levels # rubocop:disable Cop/ActiveRecordSerialize serialize :import_sources # rubocop:disable Cop/ActiveRecordSerialize serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiveRecordSerialize @@ -146,6 +151,12 @@ class ApplicationSetting < ActiveRecord::Base presence: true, numericality: { greater_than_or_equal_to: 0 } + SUPPORTED_KEY_TYPES.each do |type| + validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } + end + + validates :allowed_key_types, presence: true + validates_each :restricted_visibility_levels do |record, attr, value| value&.each do |level| unless Gitlab::VisibilityLevel.options.value?(level) @@ -171,6 +182,7 @@ class ApplicationSetting < ActiveRecord::Base end before_validation :ensure_uuid! + before_save :ensure_runners_registration_token before_save :ensure_health_check_access_token @@ -221,6 +233,9 @@ class ApplicationSetting < ActiveRecord::Base default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'], disabled_oauth_sign_in_sources: [], domain_whitelist: Settings.gitlab['domain_whitelist'], + dsa_key_restriction: 0, + ecdsa_key_restriction: 0, + ed25519_key_restriction: 0, gravatar_enabled: Settings.gravatar['enabled'], help_page_text: nil, help_page_hide_commercial_content: false, @@ -239,6 +254,7 @@ class ApplicationSetting < ActiveRecord::Base max_attachment_size: Settings.gitlab['max_attachment_size'], password_authentication_enabled: Settings.gitlab['password_authentication_enabled'], performance_bar_allowed_group_id: nil, + rsa_key_restriction: 0, plantuml_enabled: false, plantuml_url: nil, project_export_enabled: true, @@ -413,6 +429,18 @@ class ApplicationSetting < ActiveRecord::Base usage_ping_can_be_configured? && super end + def allowed_key_types + SUPPORTED_KEY_TYPES.select do |type| + key_restriction_for(type) != FORBIDDEN_KEY_VALUE + end + end + + def key_restriction_for(type) + attr_name = "#{type}_key_restriction" + + has_attribute?(attr_name) ? public_send(attr_name) : FORBIDDEN_KEY_VALUE # rubocop:disable GitlabSecurity/PublicSend + end + private def ensure_uuid! diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index 91b62dabbcd..4d1a15c53aa 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -17,6 +17,9 @@ class AwardEmoji < ActiveRecord::Base scope :downvotes, -> { where(name: DOWNVOTE_NAME) } scope :upvotes, -> { where(name: UPVOTE_NAME) } + after_save :expire_etag_cache + after_destroy :expire_etag_cache + class << self def votes_for_collection(ids, type) select('name', 'awardable_id', 'COUNT(*) as count') @@ -32,4 +35,8 @@ class AwardEmoji < ActiveRecord::Base def upvote? self.name == UPVOTE_NAME end + + def expire_etag_cache + awardable.try(:expire_etag_cache) + end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 8adaafe6439..ba3156154ac 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -3,6 +3,7 @@ module Ci include TokenAuthenticatable include AfterCommitQueue include Presentable + include Importable belongs_to :runner belongs_to :trigger_request @@ -26,6 +27,7 @@ module Ci validates :coverage, numericality: true, allow_blank: true validates :ref, presence: true + validates :protected, inclusion: { in: [true, false], unless: :importing? }, on: :create scope :unstarted, ->() { where(runner_id: nil) } scope :ignore_failures, ->() { where(allow_failure: false) } @@ -34,6 +36,7 @@ module Ci scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + [:manual]) } + scope :ref_protected, -> { where(protected: true) } mount_uploader :artifacts_file, ArtifactUploader mount_uploader :artifacts_metadata, ArtifactUploader diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 2d40f8012a3..35d14b6e297 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -36,6 +36,7 @@ module Ci validates :sha, presence: { unless: :importing? } validates :ref, presence: { unless: :importing? } validates :status, presence: { unless: :importing? } + validates :protected, inclusion: { in: [true, false], unless: :importing? }, on: :create validate :valid_commit_sha, unless: :importing? after_create :keep_around_commits, unless: :importing? @@ -304,6 +305,10 @@ module Ci @stage_seeds ||= config_processor.stage_seeds(self) end + def has_kubernetes_active? + project.kubernetes_service&.active? + end + def has_stage_seeds? stage_seeds.any? end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 906a76ec560..b1798084787 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -5,7 +5,7 @@ module Ci RUNNER_QUEUE_EXPIRY_TIME = 60.minutes ONLINE_CONTACT_TIMEOUT = 1.hour AVAILABLE_SCOPES = %w[specific shared active paused online].freeze - FORM_EDITABLE = %i[description tag_list active run_untagged locked].freeze + FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level].freeze has_many :builds has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -35,11 +35,17 @@ module Ci end validate :tag_constraints + validates :access_level, presence: true acts_as_taggable after_destroy :cleanup_runner_queue + enum access_level: { + not_protected: 0, + ref_protected: 1 + } + # Searches for runners matching the given query. # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. @@ -106,6 +112,8 @@ module Ci end def can_pick?(build) + return false if self.ref_protected? && !build.protected? + assignable_for?(build.project) && accepting_tags?(build) end diff --git a/app/models/ci/trigger_request.rb b/app/models/ci/trigger_request.rb index c58ce5c3717..2c860598281 100644 --- a/app/models/ci/trigger_request.rb +++ b/app/models/ci/trigger_request.rb @@ -6,6 +6,10 @@ module Ci belongs_to :pipeline, foreign_key: :commit_id has_many :builds + # We switched to Ci::PipelineVariable from Ci::TriggerRequest.variables. + # Ci::TriggerRequest doesn't save variables anymore. + validates :variables, absence: true + serialize :variables # rubocop:disable Cop/ActiveRecordSerialize def user_variables diff --git a/app/models/commit.rb b/app/models/commit.rb index d41c88b4e30..ba3845df867 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -251,6 +251,28 @@ class Commit project.repository.next_branch("cherry-pick-#{short_id}", mild: true) end + def cherry_pick_description(user) + message_body = "(cherry picked from commit #{sha})" + + if merged_merge_request?(user) + commits_in_merge_request = merged_merge_request(user).commits + + if commits_in_merge_request.present? + message_body << "\n" + + commits_in_merge_request.reverse.each do |commit_in_merge| + message_body << "\n#{commit_in_merge.short_id} #{commit_in_merge.title}" + end + end + end + + message_body + end + + def cherry_pick_message(user) + %Q{#{message}\n\n#{cherry_pick_description(user)}} + end + def revert_description(user) if merged_merge_request?(user) "This reverts merge request #{merged_merge_request(user).to_reference}" @@ -383,6 +405,6 @@ class Commit end def gpg_commit - @gpg_commit ||= Gitlab::Gpg::Commit.for_commit(self) + @gpg_commit ||= Gitlab::Gpg::Commit.new(self) end end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 842c6e5cb50..f3888528940 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -38,6 +38,14 @@ class CommitStatus < ActiveRecord::Base scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) } scope :after_stage, -> (index) { where('stage_idx > ?', index) } + enum failure_reason: { + unknown_failure: nil, + script_failure: 1, + api_failure: 2, + stuck_or_timeout_failure: 3, + runner_system_failure: 4 + } + state_machine :status do event :process do transition [:skipped, :manual] => :created @@ -79,6 +87,11 @@ class CommitStatus < ActiveRecord::Base commit_status.finished_at = Time.now end + before_transition any => :failed do |commit_status, transition| + failure_reason = transition.args.first + commit_status.failure_reason = failure_reason + end + after_transition do |commit_status, transition| next if transition.loopback? diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 3731b7c8577..681c3241dbb 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -6,6 +6,7 @@ # module Issuable extend ActiveSupport::Concern + include Gitlab::SQL::Pattern include CacheMarkdownField include Participable include Mentionable @@ -122,7 +123,9 @@ module Issuable # # Returns an ActiveRecord::Relation. def search(query) - where(arel_table[:title].matches("%#{query}%")) + title = to_fuzzy_arel(:title, query) + + where(title) end # Searches for records with a matching title or description. @@ -133,10 +136,10 @@ module Issuable # # Returns an ActiveRecord::Relation. def full_search(query) - t = arel_table - pattern = "%#{query}%" + title = to_fuzzy_arel(:title, query) + description = to_fuzzy_arel(:description, query) - where(t[:title].matches(pattern).or(t[:description].matches(pattern))) + where(title&.or(description)) end def sort(method, excluded_labels: []) diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index c7bdc997eca..1c4ddabcad5 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -24,6 +24,10 @@ module Noteable DiscussionNote::NOTEABLE_TYPES.include?(base_class_name) end + def discussions_rendered_on_frontend? + false + end + def discussion_notes notes end @@ -38,7 +42,7 @@ module Noteable def grouped_diff_discussions(*args) # Doesn't use `discussion_notes`, because this may include commit diff notes - # besides MR diff notes, that we do no want to display on the MR Changes tab. + # besides MR diff notes, that we do not want to display on the MR Changes tab. notes.inc_relations_for_view.grouped_diff_discussions(*args) end diff --git a/app/models/discussion.rb b/app/models/discussion.rb index d1cec7613af..b80da7b246a 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -81,6 +81,10 @@ class Discussion last_note.author end + def updated? + last_updated_at != created_at + end + def id first_note.discussion_id(context_noteable) end diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 3df60ddc950..1633acd4fa9 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -56,7 +56,7 @@ class GpgKey < ActiveRecord::Base def verified_user_infos user_infos.select do |user_info| - user_info[:email] == user.email + user.verified_email?(user_info[:email]) end end @@ -64,13 +64,17 @@ class GpgKey < ActiveRecord::Base user_infos.map do |user_info| [ user_info[:email], - user_info[:email] == user.email + user.verified_email?(user_info[:email]) ] end.to_h end def verified? - emails_with_verified_status.any? { |_email, verified| verified } + emails_with_verified_status.values.any? + end + + def verified_and_belongs_to_email?(email) + emails_with_verified_status.fetch(email, false) end def update_invalid_gpg_signatures @@ -78,11 +82,14 @@ class GpgKey < ActiveRecord::Base end def revoke - GpgSignature.where(gpg_key: self, valid_signature: true).update_all( - gpg_key_id: nil, - valid_signature: false, - updated_at: Time.zone.now - ) + GpgSignature + .where(gpg_key: self) + .where.not(verification_status: GpgSignature.verification_statuses[:unknown_key]) + .update_all( + gpg_key_id: nil, + verification_status: GpgSignature.verification_statuses[:unknown_key], + updated_at: Time.zone.now + ) destroy end diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb index 50fb35c77ec..454c90d5fc4 100644 --- a/app/models/gpg_signature.rb +++ b/app/models/gpg_signature.rb @@ -1,9 +1,21 @@ class GpgSignature < ActiveRecord::Base include ShaAttribute + include IgnorableColumn + + ignore_column :valid_signature sha_attribute :commit_sha sha_attribute :gpg_key_primary_keyid + enum verification_status: { + unverified: 0, + verified: 1, + same_user_different_email: 2, + other_user: 3, + unverified_key: 4, + unknown_key: 5 + } + belongs_to :project belongs_to :gpg_key @@ -20,6 +32,6 @@ class GpgSignature < ActiveRecord::Base end def gpg_commit - Gitlab::Gpg::Commit.new(project, commit_sha) + Gitlab::Gpg::Commit.new(commit) end end diff --git a/app/models/issue.rb b/app/models/issue.rb index dfcd4030ec3..8c7d492e605 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -269,6 +269,10 @@ class Issue < ActiveRecord::Base end end + def discussions_rendered_on_frontend? + true + end + def update_project_counter_caches? state_changed? || confidential_changed? end diff --git a/app/models/key.rb b/app/models/key.rb index 49bc26122fa..a6b4dcfec0d 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -1,6 +1,7 @@ require 'digest/md5' class Key < ActiveRecord::Base + include Gitlab::CurrentSettings include Sortable LAST_USED_AT_REFRESH_TIME = 1.day.to_i @@ -12,14 +13,18 @@ class Key < ActiveRecord::Base validates :title, presence: true, length: { maximum: 255 } + validates :key, presence: true, length: { maximum: 5000 }, format: { with: /\A(ssh|ecdsa)-.*\Z/ } + validates :fingerprint, uniqueness: true, presence: { message: 'cannot be generated' } + validate :key_meets_restrictions + delegate :name, :email, to: :user, prefix: true after_commit :add_to_shell, on: :create @@ -80,6 +85,10 @@ class Key < ActiveRecord::Base SystemHooksService.new.execute_hooks_for(self, :destroy) end + def public_key + @public_key ||= Gitlab::SSHPublicKey.new(key) + end + private def generate_fingerprint @@ -87,7 +96,27 @@ class Key < ActiveRecord::Base return unless self.key.present? - self.fingerprint = Gitlab::KeyFingerprint.new(self.key).fingerprint + self.fingerprint = public_key.fingerprint + end + + def key_meets_restrictions + restriction = current_application_settings.key_restriction_for(public_key.type) + + if restriction == ApplicationSetting::FORBIDDEN_KEY_VALUE + errors.add(:key, forbidden_key_type_message) + elsif public_key.bits < restriction + errors.add(:key, "must be at least #{restriction} bits") + end + end + + def forbidden_key_type_message + allowed_types = + current_application_settings + .allowed_key_types + .map(&:upcase) + .to_sentence(last_word_connector: ', or ', two_words_connector: ' or ') + + "type is forbidden. Must be #{allowed_types}" end def notify_user diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index ca3a1806ee8..724fb4ccef1 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -605,6 +605,8 @@ class MergeRequest < ActiveRecord::Base self.merge_requests_closing_issues.delete_all closes_issues(current_user).each do |issue| + next if issue.is_a?(ExternalIssue) + self.merge_requests_closing_issues.create!(issue: issue) end end @@ -955,13 +957,6 @@ class MergeRequest < ActiveRecord::Base private def write_ref - target_project.repository.with_repo_branch_commit( - source_project.repository, source_branch) do |commit| - if commit - target_project.repository.write_ref(ref_path, commit.sha) - else - raise Rugged::ReferenceError, 'source repository is empty' - end - end + target_project.repository.fetch_source_branch(source_project.repository, source_branch, ref_path) end end diff --git a/app/models/note.rb b/app/models/note.rb index a752c897d63..1073c115630 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -299,6 +299,17 @@ class Note < ActiveRecord::Base end end + def expire_etag_cache + return unless noteable&.discussions_rendered_on_frontend? + + key = Gitlab::Routing.url_helpers.project_noteable_notes_path( + project, + target_type: noteable_type.underscore, + target_id: noteable_id + ) + Gitlab::EtagCaching::Store.new.touch(key) + end + private def keep_around_commit @@ -326,15 +337,4 @@ class Note < ActiveRecord::Base def set_discussion_id self.discussion_id ||= discussion_class.discussion_id(self) end - - def expire_etag_cache - return unless for_issue? - - key = Gitlab::Routing.url_helpers.project_noteable_notes_path( - noteable.project, - target_type: noteable_type.underscore, - target_id: noteable.id - ) - Gitlab::EtagCaching::Store.new.touch(key) - end end diff --git a/app/models/project.rb b/app/models/project.rb index 5b4904a5c51..051c4c8e2ec 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -68,7 +68,6 @@ class Project < ActiveRecord::Base acts_as_taggable - attr_accessor :new_default_branch attr_accessor :old_path_with_namespace attr_accessor :template_name attr_writer :pipeline_status @@ -223,6 +222,7 @@ class Project < ActiveRecord::Base validates :import_url, importable_url: true, if: [:external_import?, :import_url_changed?] validates :star_count, numericality: { greater_than_or_equal_to: 0 } validate :check_limit, on: :create + validate :can_create_repository?, on: [:create, :update], if: ->(project) { !project.persisted? || project.renamed? } validate :avatar_type, if: ->(project) { project.avatar.present? && project.avatar_changed? } validates :avatar, file_size: { maximum: 200.kilobytes.to_i } @@ -465,7 +465,7 @@ class Project < ActiveRecord::Base end def repository_storage_path - Gitlab.config.repositories.storages[repository_storage]['path'] + Gitlab.config.repositories.storages[repository_storage].try(:[], 'path') end def team @@ -580,7 +580,7 @@ class Project < ActiveRecord::Base end def valid_import_url? - valid? || errors.messages[:import_url].nil? + valid?(:import_url) || errors.messages[:import_url].nil? end def create_or_update_import_data(data: nil, credentials: nil) @@ -997,6 +997,20 @@ class Project < ActiveRecord::Base end end + # Check if repository already exists on disk + def can_create_repository? + return false unless repository_storage_path + + expires_full_path_cache # we need to clear cache to validate renames correctly + + if gitlab_shell.exists?(repository_storage_path, "#{disk_path}.git") + errors.add(:base, 'There is already a repository with that name on disk') + return false + end + + true + end + def create_repository(force: false) # Forked import is handled asynchronously return if forked? && !force @@ -1487,6 +1501,10 @@ class Project < ActiveRecord::Base self.storage_version.nil? end + def renamed? + persisted? && path_changed? + end + private def storage diff --git a/app/models/repository.rb b/app/models/repository.rb index b3fa51a14f7..035f85a0b46 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -20,7 +20,6 @@ class Repository delegate :ref_name_for_sha, to: :raw_repository - CommitError = Class.new(StandardError) CreateTreeError = Class.new(StandardError) # Methods that cache data from the Git repository. @@ -95,19 +94,6 @@ class Repository "#<#{self.class.name}:#{@disk_path}>" end - # - # Git repository can contains some hidden refs like: - # /refs/notes/* - # /refs/git-as-svn/* - # /refs/pulls/* - # This refs by default not visible in project page and not cloned to client side. - # - # This method return true if repository contains some content visible in project page. - # - def has_visible_content? - branch_count > 0 - end - def commit(ref = 'HEAD') return nil unless exists? @@ -180,32 +166,25 @@ class Repository end def add_branch(user, branch_name, ref) - newrev = commit(ref).try(:sha) - - return false unless newrev - - GitOperationService.new(user, self).add_branch(branch_name, newrev) + branch = raw_repository.add_branch(branch_name, committer: user, target: ref) after_create_branch - find_branch(branch_name) + + branch + rescue Gitlab::Git::Repository::InvalidRef + false end def add_tag(user, tag_name, target, message = nil) - newrev = commit(target).try(:id) - options = { message: message, tagger: user_to_committer(user) } if message - - return false unless newrev - - GitOperationService.new(user, self).add_tag(tag_name, newrev, options) - - find_tag(tag_name) + raw_repository.add_tag(tag_name, committer: user, target: target, message: message) + rescue Gitlab::Git::Repository::InvalidRef + false end def rm_branch(user, branch_name) before_remove_branch - branch = find_branch(branch_name) - GitOperationService.new(user, self).rm_branch(branch) + raw_repository.rm_branch(branch_name, committer: user) after_remove_branch true @@ -213,9 +192,8 @@ class Repository def rm_tag(user, tag_name) before_remove_tag - tag = find_tag(tag_name) - GitOperationService.new(user, self).rm_tag(tag) + raw_repository.rm_tag(tag_name, committer: user) after_remove_tag true @@ -784,16 +762,30 @@ class Repository multi_action(**options) end + def with_branch(user, *args) + result = Gitlab::Git::OperationService.new(user, raw_repository).with_branch(*args) do |start_commit| + yield start_commit + end + + newrev, should_run_after_create, should_run_after_create_branch = result + + after_create if should_run_after_create + after_create_branch if should_run_after_create_branch + + newrev + end + # rubocop:disable Metrics/ParameterLists def multi_action( user:, branch_name:, message:, actions:, author_email: nil, author_name: nil, start_branch_name: nil, start_project: project) - GitOperationService.new(user, self).with_branch( + with_branch( + user, branch_name, start_branch_name: start_branch_name, - start_project: start_project) do |start_commit| + start_repository: start_project.repository.raw_repository) do |start_commit| index = Gitlab::Git::Index.new(raw_repository) @@ -846,7 +838,8 @@ class Repository end def merge(user, source, merge_request, options = {}) - GitOperationService.new(user, self).with_branch( + with_branch( + user, merge_request.target_branch) do |start_commit| our_commit = start_commit.sha their_commit = source @@ -866,17 +859,18 @@ class Repository merge_request.update(in_progress_merge_commit_sha: commit_id) commit_id end - rescue Repository::CommitError # when merge_index.conflicts? + rescue Gitlab::Git::CommitError # when merge_index.conflicts? false end def revert( user, commit, branch_name, start_branch_name: nil, start_project: project) - GitOperationService.new(user, self).with_branch( + with_branch( + user, branch_name, start_branch_name: start_branch_name, - start_project: start_project) do |start_commit| + start_repository: start_project.repository.raw_repository) do |start_commit| revert_tree_id = check_revert_content(commit, start_commit.sha) unless revert_tree_id @@ -896,10 +890,11 @@ class Repository def cherry_pick( user, commit, branch_name, start_branch_name: nil, start_project: project) - GitOperationService.new(user, self).with_branch( + with_branch( + user, branch_name, start_branch_name: start_branch_name, - start_project: start_project) do |start_commit| + start_repository: start_project.repository.raw_repository) do |start_commit| cherry_pick_tree_id = check_cherry_pick_content(commit, start_commit.sha) unless cherry_pick_tree_id @@ -908,7 +903,7 @@ class Repository committer = user_to_committer(user) - create_commit(message: commit.message, + create_commit(message: commit.cherry_pick_message(user), author: { email: commit.author_email, name: commit.author_name, @@ -921,7 +916,7 @@ class Repository end def resolve_conflicts(user, branch_name, params) - GitOperationService.new(user, self).with_branch(branch_name) do + with_branch(user, branch_name) do committer = user_to_committer(user) create_commit(params.merge(author: committer, committer: committer)) @@ -1011,25 +1006,6 @@ class Repository run_git(args).first.lines.map(&:strip) end - def with_repo_branch_commit(start_repository, start_branch_name) - return yield nil if start_repository.empty_repo? - - if start_repository == self - yield commit(start_branch_name) - else - sha = start_repository.commit(start_branch_name).sha - - if branch_commit = commit(sha) - yield branch_commit - else - with_repo_tmp_commit( - start_repository, start_branch_name, sha) do |tmp_commit| - yield tmp_commit - end - end - end - end - def add_remote(name, url) raw_repository.remote_add(name, url) rescue Rugged::ConfigError @@ -1047,14 +1023,12 @@ class Repository gitlab_shell.fetch_remote(raw_repository, remote, forced: forced, no_tags: no_tags) end - def fetch_ref(source_path, source_ref, target_ref) - args = %W(fetch --no-tags -f #{source_path} #{source_ref}:#{target_ref}) - message, status = run_git(args) - - # Make sure ref was created, and raise Rugged::ReferenceError when not - raise Rugged::ReferenceError, message if status != 0 + def fetch_source_branch(source_repository, source_branch, local_ref) + raw_repository.fetch_source_branch(source_repository.raw_repository, source_branch, local_ref) + end - target_ref + def compare_source_branch(target_branch_name, source_repository, source_branch_name, straight:) + raw_repository.compare_source_branch(target_branch_name, source_repository.raw_repository, source_branch_name, straight: straight) end def create_ref(ref, ref_path) @@ -1135,12 +1109,6 @@ class Repository private - def run_git(args) - circuit_breaker.perform do - Gitlab::Popen.popen([Gitlab.config.git.bin_path, *args], path_to_repo) - end - end - def blob_data_at(sha, path) blob = blob_at(sha, path) return unless blob @@ -1236,16 +1204,4 @@ class Repository .commits_by_message(query, revision: ref, path: path, limit: limit, offset: offset) .map { |c| commit(c) } end - - def with_repo_tmp_commit(start_repository, start_branch_name, sha) - tmp_ref = fetch_ref( - start_repository.path_to_repo, - "#{Gitlab::Git::BRANCH_REF_PREFIX}#{start_branch_name}", - "refs/tmp/#{SecureRandom.hex}/head" - ) - - yield commit(sha) - ensure - delete_refs(tmp_ref) if tmp_ref - end end diff --git a/app/models/user.rb b/app/models/user.rb index 78e7c750c3b..c5b5f09722f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -603,7 +603,7 @@ class User < ActiveRecord::Base end def require_personal_access_token_creation_for_git_auth? - return false if allow_password_authentication? || ldap_user? + return false if current_application_settings.password_authentication_enabled? || ldap_user? PersonalAccessTokensFinder.new(user: self, impersonation: false, state: 'active').execute.none? end @@ -644,11 +644,6 @@ class User < ActiveRecord::Base @personal_projects_count ||= personal_projects.count end - def projects_limit_percent - return 100 if projects_limit.zero? - (personal_projects.count.to_f / projects_limit) * 100 - end - def recent_push(project_ids = nil) # Get push events not earlier than 2 hours ago events = recent_events.code_push.where("created_at > ?", Time.now - 2.hours) @@ -666,10 +661,6 @@ class User < ActiveRecord::Base end end - def projects_sorted_by_activity - authorized_projects.sorted_by_activity - end - def several_namespaces? owned_groups.any? || masters_groups.any? end @@ -1050,6 +1041,10 @@ class User < ActiveRecord::Base ensure_rss_token! end + def verified_email?(email) + self.email == email + end + protected # override, from Devise::Validatable diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 5c7c2204374..f2315bb3dbb 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -84,7 +84,7 @@ class WikiPage # The formatted title of this page. def title if @attributes[:title] - self.class.unhyphenize(@attributes[:title]) + CGI.unescape_html(self.class.unhyphenize(@attributes[:title])) else "" end diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb index c495c3f39bb..255475e1fe6 100644 --- a/app/presenters/ci/build_presenter.rb +++ b/app/presenters/ci/build_presenter.rb @@ -17,5 +17,16 @@ module Ci "Job is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}" end end + + def trigger_variables + return [] unless trigger_request + + @trigger_variables ||= + if pipeline.variables.any? + pipeline.variables.map(&:to_runner_variable) + else + trigger_request.user_variables + end + end end end diff --git a/app/serializers/award_emoji_entity.rb b/app/serializers/award_emoji_entity.rb new file mode 100644 index 00000000000..6e03cd02392 --- /dev/null +++ b/app/serializers/award_emoji_entity.rb @@ -0,0 +1,4 @@ +class AwardEmojiEntity < Grape::Entity + expose :name + expose :user, using: API::Entities::UserSafe +end diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb new file mode 100644 index 00000000000..0a92e3f8167 --- /dev/null +++ b/app/serializers/discussion_entity.rb @@ -0,0 +1,10 @@ +class DiscussionEntity < Grape::Entity + include RequestAwareEntity + + expose :id, :reply_id + expose :expanded?, as: :expanded + + expose :notes, using: NoteEntity + + expose :individual_note?, as: :individual_note +end diff --git a/app/serializers/discussion_serializer.rb b/app/serializers/discussion_serializer.rb new file mode 100644 index 00000000000..ed5e1224bb2 --- /dev/null +++ b/app/serializers/discussion_serializer.rb @@ -0,0 +1,3 @@ +class DiscussionSerializer < BaseSerializer + entity DiscussionEntity +end diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb index bd5211b8e58..61c7a428745 100644 --- a/app/serializers/issuable_entity.rb +++ b/app/serializers/issuable_entity.rb @@ -15,4 +15,6 @@ class IssuableEntity < Grape::Entity expose :total_time_spent expose :human_time_estimate expose :human_total_time_spent + expose :milestone, using: API::Entities::Milestone + expose :labels, using: LabelEntity end diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index c189a4992da..0d6feb78173 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -7,10 +7,26 @@ class IssueEntity < IssuableEntity expose :due_date expose :moved_to_id expose :project_id - expose :milestone, using: API::Entities::Milestone - expose :labels, using: LabelEntity expose :web_url do |issue| project_issue_path(issue.project, issue) end + + expose :current_user do + expose :can_create_note do |issue| + can?(request.current_user, :create_note, issue.project) + end + + expose :can_update do |issue| + can?(request.current_user, :update_issue, issue) + end + end + + expose :create_note_path do |issue| + project_notes_path(issue.project, target_type: 'issue', target_id: issue.id) + end + + expose :preview_note_path do |issue| + preview_markdown_path(issue.project, quick_actions_target_type: 'Issue', quick_actions_target_id: issue.id) + end end diff --git a/app/serializers/note_attachment_entity.rb b/app/serializers/note_attachment_entity.rb new file mode 100644 index 00000000000..1ad50568ab9 --- /dev/null +++ b/app/serializers/note_attachment_entity.rb @@ -0,0 +1,5 @@ +class NoteAttachmentEntity < Grape::Entity + expose :url + expose :filename + expose :image?, as: :image +end diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb new file mode 100644 index 00000000000..7d50e0ff10d --- /dev/null +++ b/app/serializers/note_entity.rb @@ -0,0 +1,60 @@ +class NoteEntity < API::Entities::Note + include RequestAwareEntity + + expose :type + + expose :author, using: NoteUserEntity + + expose :human_access do |note| + note.project.team.human_max_access(note.author_id) + end + + unexpose :note, as: :body + expose :note + + expose :redacted_note_html, as: :note_html + + expose :last_edited_at, if: -> (note, _) { note.edited? } + expose :last_edited_by, using: NoteUserEntity, if: -> (note, _) { note.edited? } + + expose :current_user do + expose :can_edit do |note| + Ability.can_edit_note?(request.current_user, note) + end + end + + expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note| + SystemNoteHelper.system_note_icon_name(note) + end + + expose :discussion_id do |note| + note.discussion_id(request.noteable) + end + + expose :emoji_awardable?, as: :emoji_awardable + expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity + expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note| + if note.for_personal_snippet? + toggle_award_emoji_snippet_note_path(note.noteable, note) + else + toggle_award_emoji_project_note_path(note.project, note.id) + end + end + + expose :report_abuse_path do |note| + new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note)) + end + + expose :path do |note| + if note.for_personal_snippet? + snippet_note_path(note.noteable, note) + else + project_note_path(note.project, note) + end + end + + expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? } + expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note| + delete_attachment_project_note_path(note.project, note) + end +end diff --git a/app/serializers/note_serializer.rb b/app/serializers/note_serializer.rb new file mode 100644 index 00000000000..2afe40d7a34 --- /dev/null +++ b/app/serializers/note_serializer.rb @@ -0,0 +1,3 @@ +class NoteSerializer < BaseSerializer + entity NoteEntity +end diff --git a/app/serializers/note_user_entity.rb b/app/serializers/note_user_entity.rb new file mode 100644 index 00000000000..7289f3a0222 --- /dev/null +++ b/app/serializers/note_user_entity.rb @@ -0,0 +1,3 @@ +class NoteUserEntity < UserEntity + unexpose :web_url +end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb new file mode 100644 index 00000000000..49a71ebac61 --- /dev/null +++ b/app/serializers/user_serializer.rb @@ -0,0 +1,3 @@ +class UserSerializer < BaseSerializer + entity UserEntity +end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index de2cd7e87be..414c01b2546 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -12,7 +12,8 @@ module Ci tag: tag?, trigger_requests: Array(trigger_request), user: current_user, - pipeline_schedule: schedule + pipeline_schedule: schedule, + protected: project.protected_for?(ref) ) result = validate(current_user, diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb deleted file mode 100644 index b2aa457bbd5..00000000000 --- a/app/services/ci/create_trigger_request_service.rb +++ /dev/null @@ -1,19 +0,0 @@ -# This class is deprecated because we're closing Ci::TriggerRequest. -# New class is PipelineTriggerService (app/services/ci/pipeline_trigger_service.rb) -# which is integrated with Ci::PipelineVariable instaed of Ci::TriggerRequest. -# We remove this class after we removed v1 and v3 API. This class is still being -# referred by such legacy code. -module Ci - module CreateTriggerRequestService - Result = Struct.new(:trigger_request, :pipeline) - - def self.execute(project, trigger, ref, variables = nil) - trigger_request = trigger.trigger_requests.create(variables: variables) - - pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: ref) - .execute(:trigger, ignore_skip_ci: true, trigger_request: trigger_request) - - Result.new(trigger_request, pipeline) - end - end -end diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 414f672cc6a..b8db709211a 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -77,7 +77,9 @@ module Ci end def new_builds - Ci::Build.pending.unstarted + builds = Ci::Build.pending.unstarted + builds = builds.ref_protected if runner.ref_protected? + builds end def shared_runner_build_limits_feature_enabled? diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index ea3b8d66ed9..d67b9f5cc56 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -3,7 +3,7 @@ module Ci CLONE_ACCESSORS = %i[pipeline project ref tag options commands name allow_failure stage_id stage stage_idx trigger_request yaml_variables when environment coverage_regex - description tag_list].freeze + description tag_list protected].freeze def execute(build) reprocess!(build).tap do |new_build| diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb index dbd0b9ef43a..f96f2931508 100644 --- a/app/services/commits/create_service.rb +++ b/app/services/commits/create_service.rb @@ -17,7 +17,7 @@ module Commits new_commit = create_commit! success(result: new_commit) - rescue ValidationError, ChangeError, Gitlab::Git::Index::IndexError, Repository::CommitError, Gitlab::Git::HooksService::PreReceiveError => ex + rescue ValidationError, ChangeError, Gitlab::Git::Index::IndexError, Gitlab::Git::CommitError, Gitlab::Git::HooksService::PreReceiveError => ex error(ex.message) end diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb index a5ae4927412..53f16a236d2 100644 --- a/app/services/compare_service.rb +++ b/app/services/compare_service.rb @@ -11,26 +11,8 @@ class CompareService end def execute(target_project, target_branch, straight: false) - # If compare with other project we need to fetch ref first - target_project.repository.with_repo_branch_commit( - start_project.repository, - start_branch_name) do |commit| - break unless commit + raw_compare = target_project.repository.compare_source_branch(target_branch, start_project.repository, start_branch_name, straight: straight) - compare(commit.sha, target_project, target_branch, straight: straight) - end - end - - private - - def compare(source_sha, target_project, target_branch, straight:) - raw_compare = Gitlab::Git::Compare.new( - target_project.repository.raw_repository, - target_branch, - source_sha, - straight: straight - ) - - Compare.new(raw_compare, target_project, straight: straight) + Compare.new(raw_compare, target_project, straight: straight) if raw_compare end end diff --git a/app/services/git_operation_service.rb b/app/services/git_operation_service.rb deleted file mode 100644 index 6b7a56e6922..00000000000 --- a/app/services/git_operation_service.rb +++ /dev/null @@ -1,159 +0,0 @@ -class GitOperationService - attr_reader :committer, :repository - - def initialize(committer, new_repository) - committer = Gitlab::Git::Committer.from_user(committer) if committer.is_a?(User) - @committer = committer - - @repository = new_repository - end - - def add_branch(branch_name, newrev) - ref = Gitlab::Git::BRANCH_REF_PREFIX + branch_name - oldrev = Gitlab::Git::BLANK_SHA - - update_ref_in_hooks(ref, newrev, oldrev) - end - - def rm_branch(branch) - ref = Gitlab::Git::BRANCH_REF_PREFIX + branch.name - oldrev = branch.target - newrev = Gitlab::Git::BLANK_SHA - - update_ref_in_hooks(ref, newrev, oldrev) - end - - def add_tag(tag_name, newrev, options = {}) - ref = Gitlab::Git::TAG_REF_PREFIX + tag_name - oldrev = Gitlab::Git::BLANK_SHA - - with_hooks(ref, newrev, oldrev) do |service| - # We want to pass the OID of the tag object to the hooks. For an - # annotated tag we don't know that OID until after the tag object - # (raw_tag) is created in the repository. That is why we have to - # update the value after creating the tag object. Only the - # "post-receive" hook will receive the correct value in this case. - raw_tag = repository.rugged.tags.create(tag_name, newrev, options) - service.newrev = raw_tag.target_id - end - end - - def rm_tag(tag) - ref = Gitlab::Git::TAG_REF_PREFIX + tag.name - oldrev = tag.target - newrev = Gitlab::Git::BLANK_SHA - - update_ref_in_hooks(ref, newrev, oldrev) do - repository.rugged.tags.delete(tag_name) - end - end - - # Whenever `start_branch_name` is passed, if `branch_name` doesn't exist, - # it would be created from `start_branch_name`. - # If `start_project` is passed, and the branch doesn't exist, - # it would try to find the commits from it instead of current repository. - def with_branch( - branch_name, - start_branch_name: nil, - start_project: repository.project, - &block) - - start_repository = start_project.repository - start_branch_name = nil if start_repository.empty_repo? - - if start_branch_name && !start_repository.branch_exists?(start_branch_name) - raise ArgumentError, "Cannot find branch #{start_branch_name} in #{start_repository.full_path}" - end - - update_branch_with_hooks(branch_name) do - repository.with_repo_branch_commit( - start_repository, - start_branch_name || branch_name, - &block) - end - end - - private - - def update_branch_with_hooks(branch_name) - update_autocrlf_option - - was_empty = repository.empty? - - # Make commit - newrev = yield - - unless newrev - raise Repository::CommitError.new('Failed to create commit') - end - - branch = repository.find_branch(branch_name) - oldrev = find_oldrev_from_branch(newrev, branch) - - ref = Gitlab::Git::BRANCH_REF_PREFIX + branch_name - update_ref_in_hooks(ref, newrev, oldrev) - - # If repo was empty expire cache - repository.after_create if was_empty - repository.after_create_branch if - was_empty || Gitlab::Git.blank_ref?(oldrev) - - newrev - end - - def find_oldrev_from_branch(newrev, branch) - return Gitlab::Git::BLANK_SHA unless branch - - oldrev = branch.target - - if oldrev == repository.rugged.merge_base(newrev, branch.target) - oldrev - else - raise Repository::CommitError.new('Branch diverged') - end - end - - def update_ref_in_hooks(ref, newrev, oldrev) - with_hooks(ref, newrev, oldrev) do - update_ref(ref, newrev, oldrev) - end - end - - def with_hooks(ref, newrev, oldrev) - Gitlab::Git::HooksService.new.execute( - committer, - repository, - oldrev, - newrev, - ref) do |service| - - yield(service) - end - end - - # Gitaly note: JV: wait with migrating #update_ref until we know how to migrate its call sites. - def update_ref(ref, newrev, oldrev) - # We use 'git update-ref' because libgit2/rugged currently does not - # offer 'compare and swap' ref updates. Without compare-and-swap we can - # (and have!) accidentally reset the ref to an earlier state, clobbering - # commits. See also https://github.com/libgit2/libgit2/issues/1534. - command = %W[#{Gitlab.config.git.bin_path} update-ref --stdin -z] - _, status = Gitlab::Popen.popen( - command, - repository.path_to_repo) do |stdin| - stdin.write("update #{ref}\x00#{newrev}\x00#{oldrev}\x00") - end - - unless status.zero? - raise Repository::CommitError.new( - "Could not update branch #{Gitlab::Git.branch_name(ref)}." \ - " Please refresh and try again.") - end - end - - def update_autocrlf_option - if repository.raw_repository.autocrlf != :input - repository.raw_repository.autocrlf = :input - end - end -end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 1486db046b5..8b967b78052 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -56,6 +56,7 @@ class IssuableBaseService < BaseService params.delete(:assignee_id) params.delete(:due_date) params.delete(:canonical_issue_id) + params.delete(:project) end filter_assignee(issuable) @@ -244,9 +245,7 @@ class IssuableBaseService < BaseService new_assignees = issuable.assignees.to_a affected_assignees = (old_assignees + new_assignees) - (old_assignees & new_assignees) - # Don't clear the project cache, because it will be handled by the - # appropriate service (close / reopen / merge / etc.). - invalidate_cache_counts(issuable, users: affected_assignees.compact, skip_project_cache: true) + invalidate_cache_counts(issuable, users: affected_assignees.compact) after_update(issuable) issuable.create_new_cross_references!(current_user) execute_hooks(issuable, 'update') @@ -340,18 +339,9 @@ class IssuableBaseService < BaseService create_labels_note(issuable, old_labels) if issuable.labels != old_labels end - def invalidate_cache_counts(issuable, users: [], skip_project_cache: false) + def invalidate_cache_counts(issuable, users: []) users.each do |user| user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend end - - unless skip_project_cache - case issuable - when Issue - IssuesFinder.new(nil, project_id: issuable.project_id).clear_caches! - when MergeRequest - MergeRequestsFinder.new(nil, project_id: issuable.target_project_id).clear_caches! - end - end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 8d918ccc635..deb4990eb4f 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -6,7 +6,7 @@ module Issues handle_move_between_iids(issue) filter_spam_check_params change_issue_duplicate(issue) - update(issue) + move_issue_to_new_project(issue) || update(issue) end def before_update(issue) @@ -74,6 +74,17 @@ module Issues end end + def move_issue_to_new_project(issue) + target_project = params.delete(:target_project) + + return unless target_project && + issue.can_move?(current_user, target_project) && + target_project != issue.project + + update(issue) + Issues::MoveService.new(project, current_user).execute(issue, target_project) + end + private def get_issue_if_allowed(project, iid) diff --git a/app/services/projects/after_import_service.rb b/app/services/projects/after_import_service.rb index e6a68d983ef..3047268b2d1 100644 --- a/app/services/projects/after_import_service.rb +++ b/app/services/projects/after_import_service.rb @@ -1,7 +1,6 @@ module Projects class AfterImportService - RESERVED_REFS_REGEXP = - %r{\Arefs/(?:#{Regexp.union(*Repository::RESERVED_REFS_NAMES)})/} + RESERVED_REF_PREFIXES = Repository::RESERVED_REFS_NAMES.map { |n| File.join('refs', n, '/') } def initialize(project) @project = project @@ -9,7 +8,7 @@ module Projects def execute Projects::HousekeepingService.new(@project).execute do - repository.delete_refs(*garbage_refs) + repository.delete_all_refs_except(RESERVED_REF_PREFIXES) end rescue Projects::HousekeepingService::LeaseTaken => e Rails.logger.info( @@ -18,10 +17,6 @@ module Projects private - def garbage_refs - @garbage_refs ||= repository.all_ref_names_except(RESERVED_REFS_REGEXP) - end - def repository @repository ||= @project.repository end diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index f6b83a2f621..d34903c9989 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -53,7 +53,7 @@ module Projects log_error("Projects::UpdatePagesService: #{message}") @status.allow_failure = !latest? @status.description = message - @status.drop + @status.drop(:script_failure) super end diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index c7832c47e1a..9cdb9935bea 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -505,6 +505,24 @@ module QuickActions end end + desc 'Move this issue to another project.' + explanation do |path_to_project| + "Moves this issue to #{path_to_project}." + end + params 'path/to/project' + condition do + issuable.is_a?(Issue) && + issuable.persisted? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :move do |target_project_path| + target_project = Project.find_by_full_path(target_project_path) + + if target_project.present? + @updates[:target_project] = target_project + end + end + def extract_users(params) return [] if params.nil? diff --git a/app/validators/key_restriction_validator.rb b/app/validators/key_restriction_validator.rb new file mode 100644 index 00000000000..204be827941 --- /dev/null +++ b/app/validators/key_restriction_validator.rb @@ -0,0 +1,29 @@ +class KeyRestrictionValidator < ActiveModel::EachValidator + FORBIDDEN = -1 + + def self.supported_sizes(type) + Gitlab::SSHPublicKey.supported_sizes(type) + end + + def self.supported_key_restrictions(type) + [0, *supported_sizes(type), FORBIDDEN] + end + + def validate_each(record, attribute, value) + unless valid_restriction?(value) + record.errors.add(attribute, "must be forbidden, allowed, or one of these sizes: #{supported_sizes_message}") + end + end + + private + + def supported_sizes_message + sizes = self.class.supported_sizes(options[:type]) + sizes.to_sentence(last_word_connector: ', or ', two_words_connector: ' or ') + end + + def valid_restriction?(value) + choices = self.class.supported_key_restrictions(options[:type]) + choices.include?(value) + end +end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 734a08c61fa..a010b4691bf 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -42,12 +42,7 @@ = link_to "(?)", help_page_path("integration/bitbucket") and GitLab.com = link_to "(?)", help_page_path("integration/gitlab") - .form-group - %label.control-label.col-sm-2 Enabled Git access protocols - .col-sm-10 - = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') - %span.help-block#clone-protocol-help - Allow only the selected protocols to be used for Git access. + .form-group .col-sm-offset-2.col-sm-10 .checkbox @@ -55,6 +50,20 @@ = f.check_box :project_export_enabled Project export enabled + .form-group + %label.control-label.col-sm-2 Enabled Git access protocols + .col-sm-10 + = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') + %span.help-block#clone-protocol-help + Allow only the selected protocols to be used for Git access. + + - ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type| + - field_name = :"#{type}_key_restriction" + .form-group + = f.label field_name, "#{type.upcase} SSH keys", class: 'control-label col-sm-2' + .col-sm-10 + = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control' + %fieldset %legend Account and Limit Settings .form-group @@ -153,7 +162,7 @@ .checkbox = f.label :password_authentication_enabled do = f.check_box :password_authentication_enabled - Password authentication enabled + Sign-in enabled - if omniauth_enabled? && button_based_providers.any? .form-group = f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'control-label col-sm-2' @@ -530,24 +539,27 @@ .help-block If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database. - %fieldset - %legend Koding - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :koding_enabled do - = f.check_box :koding_enabled - Enable Koding - .form-group - = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2' - .col-sm-10 - = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090' - .help-block - Koding has integration enabled out of the box for the - %strong gitlab - team, and you need to provide that team's URL here. Learn more in the - = succeed "." do - = link_to "Koding administration documentation", help_page_path("administration/integration/koding") + - if koding_enabled? + %fieldset + %legend Koding + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :koding_enabled do + = f.check_box :koding_enabled + Enable Koding + .help-block + Koding integration has been deprecated since GitLab 10.0. If you disable your Koding integration, you will not be able to enable it again. + .form-group + = f.label :koding_url, 'Koding URL', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :koding_url, class: 'form-control', placeholder: 'http://gitlab.your-koding-instance.com:8090' + .help-block + Koding has integration enabled out of the box for the + %strong gitlab + team, and you need to provide that team's URL here. Learn more in the + = succeed "." do + = link_to "Koding administration documentation", help_page_path("administration/integration/koding") %fieldset %legend PlantUML diff --git a/app/views/ci/lints/_create.html.haml b/app/views/ci/lints/_create.html.haml index c91602fcff7..30bf1384b22 100644 --- a/app/views/ci/lints/_create.html.haml +++ b/app/views/ci/lints/_create.html.haml @@ -22,10 +22,10 @@ %b Tag list: = build[:tag_list].to_a.join(", ") %br - %b Refs only: + %b Only policy: = @jobs[build[:name].to_sym][:only].to_a.join(", ") %br - %b Refs except: + %b Except policy: = @jobs[build[:name].to_sym][:except].to_a.join(", ") %br %b Environment: diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml index 8ed23ac4919..dcfb7f0c32d 100644 --- a/app/views/ci/status/_dropdown_graph_badge.html.haml +++ b/app/views/ci/status/_dropdown_graph_badge.html.haml @@ -6,14 +6,14 @@ - tooltip = "#{subject.name} - #{status.label}" - if status.has_details? - = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip } do + = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do %span{ class: klass }= custom_icon(status.icon) %span.ci-build-text= subject.name - else - .mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip } } + .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } } %span{ class: klass }= custom_icon(status.icon) %span.ci-build-text= subject.name - if status.has_action? - = link_to status.action_path, class: 'ci-action-icon-wrapper js-ci-action-icon', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title } do + = link_to status.action_path, class: 'ci-action-icon-wrapper js-ci-action-icon', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do = custom_icon(status.action_icon) diff --git a/app/views/discussions/_headline.html.haml b/app/views/discussions/_headline.html.haml index c1dabeed387..25e90924413 100644 --- a/app/views/discussions/_headline.html.haml +++ b/app/views/discussions/_headline.html.haml @@ -5,7 +5,7 @@ by = link_to_member(@project, discussion.resolved_by, avatar: false) = time_ago_with_tooltip(discussion.resolved_at, placement: "bottom") -- elsif discussion.last_updated_at != discussion.created_at +- elsif discussion.updated? .discussion-headline-light.js-discussion-headline Last updated - if discussion.last_updated_by diff --git a/app/views/feature_highlight/_issue_boards.svg b/app/views/feature_highlight/_issue_boards.svg new file mode 100644 index 00000000000..1522c9d51c9 --- /dev/null +++ b/app/views/feature_highlight/_issue_boards.svg @@ -0,0 +1,98 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="214" height="102" viewBox="0 0 214 102" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <path id="b" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,27 C48,28.1045695 47.1045695,29 46,29 L2,29 C0.8954305,29 1.3527075e-16,28.1045695 0,27 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/> + <filter id="a" width="102.1%" height="106.9%" x="-1%" y="-1.7%" filterUnits="objectBoundingBox"> + <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/> + </filter> + <path id="d" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/> + <filter id="c" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox"> + <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/> + </filter> + <path id="e" d="M5,0 L53,0 C55.7614237,-5.07265313e-16 58,2.23857625 58,5 L58,91 C58,93.7614237 55.7614237,96 53,96 L5,96 C2.23857625,96 3.38176876e-16,93.7614237 0,91 L0,5 C-3.38176876e-16,2.23857625 2.23857625,5.07265313e-16 5,0 Z"/> + <path id="h" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/> + <filter id="g" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox"> + <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/> + </filter> + <path id="j" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/> + <filter id="i" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox"> + <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/> + </filter> + <path id="l" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/> + <filter id="k" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox"> + <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/> + </filter> + <path id="n" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/> + <filter id="m" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox"> + <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/> + </filter> + <path id="p" d="M2,0 L46,0 C47.1045695,-2.02906125e-16 48,0.8954305 48,2 L48,26 C48,27.1045695 47.1045695,28 46,28 L2,28 C0.8954305,28 1.3527075e-16,27.1045695 0,26 L0,2 C-1.3527075e-16,0.8954305 0.8954305,2.02906125e-16 2,0 Z"/> + <filter id="o" width="102.1%" height="107.1%" x="-1%" y="-1.8%" filterUnits="objectBoundingBox"> + <feOffset dy="1" in="SourceAlpha" result="shadowOffsetOuter1"/> + <feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0" in="shadowOffsetOuter1"/> + </filter> + </defs> + <g fill="none" fill-rule="evenodd"> + <path fill="#D6D4DE" d="M14,21 L62,21 C64.7614237,21 67,23.2385763 67,26 L67,112 C67,114.761424 64.7614237,117 62,117 L14,117 C11.2385763,117 9,114.761424 9,112 L9,26 C9,23.2385763 11.2385763,21 14,21 Z"/> + <g transform="translate(11 23)"> + <path fill="#FFFFFF" d="M5,0 L53,0 C55.7614237,-5.07265313e-16 58,2.23857625 58,5 L58,91 C58,93.7614237 55.7614237,96 53,96 L5,96 C2.23857625,96 3.38176876e-16,93.7614237 0,91 L0,5 C-3.38176876e-16,2.23857625 2.23857625,5.07265313e-16 5,0 Z"/> + <path fill="#FC6D26" d="M4,0 L54,0 C56.209139,-4.05812251e-16 58,1.790861 58,4 L0,4 C-2.705415e-16,1.790861 1.790861,4.05812251e-16 4,0 Z"/> + <g transform="translate(5 10)"> + <use fill="black" filter="url(#a)" xlink:href="#b"/> + <use fill="#F9F9F9" xlink:href="#b"/> + </g> + <g transform="translate(5 42)"> + <use fill="black" filter="url(#c)" xlink:href="#d"/> + <use fill="#FEF0E8" xlink:href="#d"/> + <path fill="#FEE1D3" d="M9,8 L33,8 C34.1045695,8 35,8.8954305 35,10 C35,11.1045695 34.1045695,12 33,12 L9,12 C7.8954305,12 7,11.1045695 7,10 C7,8.8954305 7.8954305,8 9,8 Z"/> + <path fill="#FDC4A8" d="M9,17 L17,17 C18.1045695,17 19,17.8954305 19,19 C19,20.1045695 18.1045695,21 17,21 L9,21 C7.8954305,21 7,20.1045695 7,19 C7,17.8954305 7.8954305,17 9,17 Z"/> + <path fill="#FC6D26" d="M24,17 L32,17 C33.1045695,17 34,17.8954305 34,19 C34,20.1045695 33.1045695,21 32,21 L24,21 C22.8954305,21 22,20.1045695 22,19 C22,17.8954305 22.8954305,17 24,17 Z"/> + </g> + </g> + <path fill="#D6D4DE" d="M148,26 L196,26 C198.761424,26 201,28.2385763 201,31 L201,117 C201,119.761424 198.761424,122 196,122 L148,122 C145.238576,122 143,119.761424 143,117 L143,31 C143,28.2385763 145.238576,26 148,26 Z"/> + <g transform="translate(145 28)"> + <mask id="f" fill="white"> + <use xlink:href="#e"/> + </mask> + <use fill="#FFFFFF" xlink:href="#e"/> + <path fill="#FC6D26" d="M4,0 L54,0 C56.209139,-4.05812251e-16 58,1.790861 58,4 L0,4 C-2.705415e-16,1.790861 1.790861,4.05812251e-16 4,0 Z" mask="url(#f)"/> + <g transform="translate(5 10)"> + <use fill="black" filter="url(#g)" xlink:href="#h"/> + <use fill="#F9F9F9" xlink:href="#h"/> + </g> + <g transform="translate(5 42)"> + <use fill="black" filter="url(#i)" xlink:href="#j"/> + <use fill="#FEF0E8" xlink:href="#j"/> + <path fill="#FEE1D3" d="M9 8L33 8C34.1045695 8 35 8.8954305 35 10 35 11.1045695 34.1045695 12 33 12L9 12C7.8954305 12 7 11.1045695 7 10 7 8.8954305 7.8954305 8 9 8zM9 17L13 17C14.1045695 17 15 17.8954305 15 19 15 20.1045695 14.1045695 21 13 21L9 21C7.8954305 21 7 20.1045695 7 19 7 17.8954305 7.8954305 17 9 17z"/> + <path fill="#FC6D26" d="M20,17 L24,17 C25.1045695,17 26,17.8954305 26,19 C26,20.1045695 25.1045695,21 24,21 L20,21 C18.8954305,21 18,20.1045695 18,19 C18,17.8954305 18.8954305,17 20,17 Z"/> + <path fill="#FDC4A8" d="M31,17 L35,17 C36.1045695,17 37,17.8954305 37,19 C37,20.1045695 36.1045695,21 35,21 L31,21 C29.8954305,21 29,20.1045695 29,19 C29,17.8954305 29.8954305,17 31,17 Z"/> + </g> + </g> + <path fill="#D6D4DE" d="M81,14 L129,14 C131.761424,14 134,16.2385763 134,19 L134,105 C134,107.761424 131.761424,110 129,110 L81,110 C78.2385763,110 76,107.761424 76,105 L76,19 C76,16.2385763 78.2385763,14 81,14 Z"/> + <g transform="translate(78 16)"> + <path fill="#FFFFFF" d="M5,0 L53,0 C55.7614237,-5.07265313e-16 58,2.23857625 58,5 L58,91 C58,93.7614237 55.7614237,96 53,96 L5,96 C2.23857625,96 3.38176876e-16,93.7614237 0,91 L0,5 C-3.38176876e-16,2.23857625 2.23857625,5.07265313e-16 5,0 Z"/> + <g transform="translate(5 10)"> + <use fill="black" filter="url(#k)" xlink:href="#l"/> + <use fill="#EFEDF8" xlink:href="#l"/> + <path fill="#E1DBF1" d="M9,8 L33,8 C34.1045695,8 35,8.8954305 35,10 C35,11.1045695 34.1045695,12 33,12 L9,12 C7.8954305,12 7,11.1045695 7,10 C7,8.8954305 7.8954305,8 9,8 Z"/> + <path fill="#6B4FBB" d="M9,17 L13,17 C14.1045695,17 15,17.8954305 15,19 C15,20.1045695 14.1045695,21 13,21 L9,21 C7.8954305,21 7,20.1045695 7,19 C7,17.8954305 7.8954305,17 9,17 Z"/> + <path fill="#C3B8E3" d="M20,17 L28,17 C29.1045695,17 30,17.8954305 30,19 C30,20.1045695 29.1045695,21 28,21 L20,21 C18.8954305,21 18,20.1045695 18,19 C18,17.8954305 18.8954305,17 20,17 Z"/> + </g> + <g transform="translate(5 42)"> + <use fill="black" filter="url(#m)" xlink:href="#n"/> + <use fill="#F9F9F9" xlink:href="#n"/> + </g> + <g transform="translate(5 74)"> + <rect width="34" height="4" x="7" y="7" fill="#E1DBF1" rx="2"/> + <use fill="black" filter="url(#o)" xlink:href="#p"/> + <use fill="#F9F9F9" xlink:href="#p"/> + </g> + <path fill="#6B4FBB" d="M4,0 L54,0 C56.209139,-4.05812251e-16 58,1.790861 58,4 L0,4 C-2.705415e-16,1.790861 1.790861,4.05812251e-16 4,0 Z"/> + </g> + </g> +</svg> diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index 12bc092d216..837ef385dd5 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -12,6 +12,8 @@ - content_for :breadcrumbs_extra do = link_to params.merge(rss_url_options), class: 'btn btn-default append-right-10' do = icon('rss') + %span.icon-label + Subscribe = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues - if group_issues_exists diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index b32cfe158bb..1d875f81041 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -74,8 +74,6 @@ = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username } %li = link_to "Settings", profile_path - %li - = link_to "Turn on new navigation", profile_preferences_path(anchor: "new-navigation") %li.divider %li = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link" diff --git a/app/views/layouts/header/_new.html.haml b/app/views/layouts/header/_new.html.haml index 2c1c23d6ea9..c84d7053cd6 100644 --- a/app/views/layouts/header/_new.html.haml +++ b/app/views/layouts/header/_new.html.haml @@ -4,7 +4,7 @@ .header-content .title-container %h1.title - = link_to root_path, title: 'Dashboard' do + = link_to root_path, title: 'Dashboard', id: 'logo' do = brand_header_logo %span.logo-text.hidden-xs = render 'shared/logo_type.svg' @@ -37,13 +37,13 @@ data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('tachometer fw') %li - = link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = custom_icon('issues') - issues_count = assigned_issuables_count(:issues) %span.badge.issues-count{ class: ('hidden' if issues_count.zero?) } = number_with_delimiter(issues_count) %li - = link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = link_to assigned_mrs_dashboard_path, title: 'Merge requests', class: 'dashboard-shortcuts-merge_requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = custom_icon('mr_bold') - merge_requests_count = assigned_issuables_count(:merge_requests) %span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) } @@ -68,8 +68,6 @@ = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username } %li = link_to "Settings", profile_path - %li - = link_to "Turn off new navigation", profile_preferences_path(anchor: "new-navigation") %li.divider %li = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link" diff --git a/app/views/layouts/nav/_new_dashboard.html.haml b/app/views/layouts/nav/_new_dashboard.html.haml index cfdfcbebc9f..e670e04928c 100644 --- a/app/views/layouts/nav/_new_dashboard.html.haml +++ b/app/views/layouts/nav/_new_dashboard.html.haml @@ -1,13 +1,16 @@ %ul.list-unstyled.navbar-sub-nav - = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "home"}) do - = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do + = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown" }) do + %a{ href: '#', title: 'Projects', data: { toggle: 'dropdown' } } Projects + = icon("chevron-down", class: "dropdown-chevron") + .dropdown-menu.projects-dropdown-menu + = render "layouts/nav/projects_dropdown/show" = nav_link(controller: ['dashboard/groups', 'explore/groups']) do = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do Groups - = nav_link(path: 'dashboard#activity', html_options: { class: "hidden-xs hidden-sm" }) do + = nav_link(path: 'dashboard#activity', html_options: { class: "hidden-xs hidden-sm hidden-md" }) do = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do Activity @@ -17,7 +20,7 @@ = icon("chevron-down", class: "dropdown-chevron") .dropdown-menu %ul - = nav_link(path: 'dashboard#activity', html_options: { class: "visible-xs visible-sm" }) do + = nav_link(path: 'dashboard#activity', html_options: { class: "visible-xs visible-sm visible-md" }) do = link_to activity_dashboard_path, title: 'Activity' do Activity @@ -31,3 +34,8 @@ %li.divider %li = link_to "Help", help_path, title: 'About GitLab CE' + + -# Shortcut to Dashboard > Projects + %li.hidden + = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do + Projects diff --git a/app/views/layouts/nav/_new_project_sidebar.html.haml b/app/views/layouts/nav/_new_project_sidebar.html.haml index f5361c7af0c..760c4c97c33 100644 --- a/app/views/layouts/nav/_new_project_sidebar.html.haml +++ b/app/views/layouts/nav/_new_project_sidebar.html.haml @@ -99,6 +99,20 @@ = link_to project_boards_path(@project), title: 'Board' do %span Board + .feature-highlight.js-feature-highlight{ disabled: true, data: { trigger: 'manual', container: 'body', toggle: 'popover', placement: 'right', highlight: 'issue-boards' } } + .feature-highlight-popover-content + = render 'feature_highlight/issue_boards.svg' + .feature-highlight-popover-sub-content + %span= _('Use') + = link_to 'Issue Boards', project_boards_path(@project) + %span= _('to create customized software development workflows like') + %strong= _('Scrum') + %span= _('or') + %strong= _('Kanban') + %hr + %button.btn-link.dismiss-feature-highlight{ type: 'button' } + %span= _("Got it! Don't show this again") + = custom_icon('thumbs_up') = nav_link(controller: :labels) do = link_to project_labels_path(@project), title: 'Labels' do diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index 26d9640e98a..448f6abedf2 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -29,7 +29,7 @@ = link_to profile_emails_path, title: 'Emails' do %span Emails - - if current_user.allow_password_authentication? + - unless current_user.ldap_user? = nav_link(controller: :passwords) do = link_to edit_profile_password_path, title: 'Password' do %span diff --git a/app/views/layouts/nav/projects_dropdown/_show.html.haml b/app/views/layouts/nav/projects_dropdown/_show.html.haml new file mode 100644 index 00000000000..a7370180bf6 --- /dev/null +++ b/app/views/layouts/nav/projects_dropdown/_show.html.haml @@ -0,0 +1,15 @@ +- project_meta = { id: @project.id, name: @project.name, namespace: @project.name_with_namespace, web_url: @project.web_url, avatar_url: @project.avatar_url } if @project&.persisted? +.projects-dropdown-container + .project-dropdown-sidebar + %ul + = nav_link(path: 'dashboard/projects#index') do + = link_to dashboard_projects_path do + = _('Your projects') + = nav_link(path: 'projects#starred') do + = link_to starred_dashboard_projects_path do + = _('Starred projects') + = nav_link(path: 'projects#trending') do + = link_to explore_root_path do + = _('Explore projects') + .project-dropdown-content + #js-projects-dropdown{ data: { user_name: current_user.username, project: project_meta } } diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index 54d56e9b873..d6db85ee87a 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -14,12 +14,4 @@ :javascript window.uploads_path = "#{project_uploads_path(project)}"; -- content_for :header_content do - .js-dropdown-menu-projects - .dropdown-menu.dropdown-select.dropdown-menu-projects - = dropdown_title("Go to a project") - = dropdown_filter("Search your projects") - = dropdown_content - = dropdown_loading - = render template: "layouts/application" diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index d2a60ac2867..103446243e5 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -1,6 +1,12 @@ %li.key-list-item .pull-left.append-right-10 - = icon 'key', class: "settings-list-icon hidden-xs" + - if key.valid? + = icon 'key', class: 'settings-list-icon hidden-xs' + - else + = icon 'exclamation-triangle', class: 'settings-list-icon hidden-xs has-tooltip', + title: key.errors.full_messages.join(', ') + + .key-list-item-info = link_to path_to_key(key, is_admin), class: "title" do = key.title diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index d44603c638c..77521417f47 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -16,6 +16,7 @@ %strong= @key.last_used_at.try(:to_s, :medium) || 'N/A' .col-md-8 + = form_errors(@key, type: 'key') unless @key.valid? %p %span.light Fingerprint: %code.key-fingerprint= @key.fingerprint diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index f08dcc0c242..9e7fe556d88 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -18,26 +18,6 @@ = scheme.name .col-sm-12 %hr - .col-lg-4.profile-settings-sidebar#new-navigation - %h4.prepend-top-0 - New Navigation - %p - This setting allows you to turn on or off the new upcoming navigation concept. - .col-lg-8.syntax-theme - .nav-wip - %p - The new navigation is currently a work-in-progress concept and is currently only usable on wide-screens. There are a number of improvements that we are working on in order to further refine our navigation. - %p - %a{ href: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/32794', target: 'blank' } Learn more - about the improvements that are coming soon! - = label_tag do - .preview= image_tag "old_nav.png" - %input.js-experiment-feature-toggle{ type: "radio", value: "false", name: "new_nav", checked: !show_new_nav? } - Old - = label_tag do - .preview= image_tag "new_nav.png" - %input.js-experiment-feature-toggle{ type: "radio", value: "true", name: "new_nav", checked: show_new_nav? } - New .col-sm-12 %hr .col-lg-4.profile-settings-sidebar diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index 97041b87c48..71424593f2e 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -1,10 +1,5 @@ - referenced_users = local_assigns.fetch(:referenced_users, nil) -- if defined?(@issue) && @issue.confidential? - .confidential-issue-warning - = confidential_icon(@issue) - %span This is a confidential issue. Your comment will not be visible to the public. - .md-area .md-header %ul.nav-links.clearfix diff --git a/app/views/projects/boards/components/sidebar/_due_date.html.haml b/app/views/projects/boards/components/sidebar/_due_date.html.haml index f44a9d49a54..e8394eab213 100644 --- a/app/views/projects/boards/components/sidebar/_due_date.html.haml +++ b/app/views/projects/boards/components/sidebar/_due_date.html.haml @@ -3,7 +3,7 @@ Due date - if can?(current_user, :admin_issue, @project) = icon("spinner spin", class: "block-loading") - = link_to "Edit", "#", class: "edit-link pull-right" + = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right" .value .value-content %span.no-value{ "v-if" => "!issue.dueDate" } diff --git a/app/views/projects/boards/components/sidebar/_labels.html.haml b/app/views/projects/boards/components/sidebar/_labels.html.haml index 7d0c35fe183..6b389736e8b 100644 --- a/app/views/projects/boards/components/sidebar/_labels.html.haml +++ b/app/views/projects/boards/components/sidebar/_labels.html.haml @@ -3,7 +3,7 @@ Labels - if can?(current_user, :admin_issue, @project) = icon("spinner spin", class: "block-loading") - = link_to "Edit", "#", class: "edit-link pull-right" + = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right" .value.issuable-show-labels %span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" } None diff --git a/app/views/projects/boards/components/sidebar/_milestone.html.haml b/app/views/projects/boards/components/sidebar/_milestone.html.haml index 002e9994ee0..a1ddb261ea3 100644 --- a/app/views/projects/boards/components/sidebar/_milestone.html.haml +++ b/app/views/projects/boards/components/sidebar/_milestone.html.haml @@ -3,7 +3,7 @@ Milestone - if can?(current_user, :admin_issue, @project) = icon("spinner spin", class: "block-loading") - = link_to "Edit", "#", class: "edit-link pull-right" + = link_to "Edit", "#", class: "js-sidebar-dropdown-toggle edit-link pull-right" .value %span.no-value{ "v-if" => "!issue.milestone" } None diff --git a/app/views/projects/commit/_invalid_signature_badge.html.haml b/app/views/projects/commit/_invalid_signature_badge.html.haml deleted file mode 100644 index 3a73aae9d95..00000000000 --- a/app/views/projects/commit/_invalid_signature_badge.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -- title = capture do - .gpg-popover-icon.invalid - = render 'shared/icons/icon_status_notfound_borderless.svg' - %div - This commit was signed with an <strong>unverified</strong> signature. - -- locals = { signature: signature, title: title, label: 'Unverified', css_classes: ['invalid'] } - -= render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_other_user_signature_badge.html.haml b/app/views/projects/commit/_other_user_signature_badge.html.haml new file mode 100644 index 00000000000..80eca96f7ce --- /dev/null +++ b/app/views/projects/commit/_other_user_signature_badge.html.haml @@ -0,0 +1,6 @@ +- title = capture do + This commit was signed with a different user's verified signature. + +- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'icon_status_notfound_borderless', show_user: true } + += render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml new file mode 100644 index 00000000000..e737de48e22 --- /dev/null +++ b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml @@ -0,0 +1,7 @@ +- title = capture do + This commit was signed with a verified signature, but the committer email + is <strong>not verified</strong> to belong to the same user. + +- locals = { signature: signature, title: title, label: 'Unverified', css_class: ['invalid'], icon: 'icon_status_notfound_borderless', show_user: true } + += render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_signature.html.haml b/app/views/projects/commit/_signature.html.haml index 60fa52557ef..145bc629380 100644 --- a/app/views/projects/commit/_signature.html.haml +++ b/app/views/projects/commit/_signature.html.haml @@ -1,5 +1,2 @@ - if signature - - if signature.valid_signature? - = render partial: 'projects/commit/valid_signature_badge', locals: { signature: signature } - - else - = render partial: 'projects/commit/invalid_signature_badge', locals: { signature: signature } + = render partial: "projects/commit/#{signature.verification_status}_signature_badge", locals: { signature: signature } diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index d06b29db838..edff018ba6d 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -1,17 +1,27 @@ -- css_classes = commit_signature_badge_classes(css_classes) +- signature = local_assigns.fetch(:signature) +- title = local_assigns.fetch(:title) +- label = local_assigns.fetch(:label) +- css_class = local_assigns.fetch(:css_class) +- icon = local_assigns.fetch(:icon) +- show_user = local_assigns.fetch(:show_user, false) + +- css_classes = commit_signature_badge_classes(css_class) - title = capture do .gpg-popover-status - = title + .gpg-popover-icon{ class: css_class } + = render "shared/icons/#{icon}.svg" + %div + = title - content = capture do - .clearfix - = content + - if show_user + .clearfix + = render partial: 'projects/commit/signature_badge_user', locals: { signature: signature } GPG Key ID: %span.monospace= signature.gpg_key_primary_keyid - = link_to('Learn more about signing commits', help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') %button{ class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'auto top', title: title, content: content } } diff --git a/app/views/projects/commit/_signature_badge_user.html.haml b/app/views/projects/commit/_signature_badge_user.html.haml new file mode 100644 index 00000000000..b20198e76db --- /dev/null +++ b/app/views/projects/commit/_signature_badge_user.html.haml @@ -0,0 +1,21 @@ +- gpg_key = signature.gpg_key +- user = gpg_key&.user +- user_name = signature.gpg_key_user_name +- user_email = signature.gpg_key_user_email + +- if user + = link_to user_path(user), class: 'gpg-popover-user-link' do + %div + = user_avatar_without_link(user: user, size: 32) + + %div + %strong= user.name + %div= user.to_reference +- else + = mail_to user_email do + %div + = user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32) + + %div + %strong= user_name + %div= user_email diff --git a/app/views/projects/commit/_unknown_key_signature_badge.html.haml b/app/views/projects/commit/_unknown_key_signature_badge.html.haml new file mode 100644 index 00000000000..75c5cf57bcc --- /dev/null +++ b/app/views/projects/commit/_unknown_key_signature_badge.html.haml @@ -0,0 +1 @@ += render partial: 'projects/commit/unverified_signature_badge', locals: { signature: signature } diff --git a/app/views/projects/commit/_unverified_key_signature_badge.html.haml b/app/views/projects/commit/_unverified_key_signature_badge.html.haml new file mode 100644 index 00000000000..75c5cf57bcc --- /dev/null +++ b/app/views/projects/commit/_unverified_key_signature_badge.html.haml @@ -0,0 +1 @@ += render partial: 'projects/commit/unverified_signature_badge', locals: { signature: signature } diff --git a/app/views/projects/commit/_unverified_signature_badge.html.haml b/app/views/projects/commit/_unverified_signature_badge.html.haml new file mode 100644 index 00000000000..1af58027b83 --- /dev/null +++ b/app/views/projects/commit/_unverified_signature_badge.html.haml @@ -0,0 +1,6 @@ +- title = capture do + This commit was signed with an <strong>unverified</strong> signature. + +- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'icon_status_notfound_borderless' } + += render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_valid_signature_badge.html.haml b/app/views/projects/commit/_valid_signature_badge.html.haml deleted file mode 100644 index db1a41bbf64..00000000000 --- a/app/views/projects/commit/_valid_signature_badge.html.haml +++ /dev/null @@ -1,32 +0,0 @@ -- title = capture do - .gpg-popover-icon.valid - = render 'shared/icons/icon_status_success_borderless.svg' - %div - This commit was signed with a <strong>verified</strong> signature. - -- content = capture do - - gpg_key = signature.gpg_key - - user = gpg_key&.user - - user_name = signature.gpg_key_user_name - - user_email = signature.gpg_key_user_email - - - if user - = link_to user_path(user), class: 'gpg-popover-user-link' do - %div - = user_avatar_without_link(user: user, size: 32) - - %div - %strong= gpg_key.user.name - %div @#{gpg_key.user.username} - - else - = mail_to user_email do - %div - = user_avatar_without_link(user_name: user_name, user_email: user_email, size: 32) - - %div - %strong= user_name - %div= user_email - -- locals = { signature: signature, title: title, content: content, label: 'Verified', css_classes: ['valid'] } - -= render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/commit/_verified_signature_badge.html.haml b/app/views/projects/commit/_verified_signature_badge.html.haml new file mode 100644 index 00000000000..423beba2120 --- /dev/null +++ b/app/views/projects/commit/_verified_signature_badge.html.haml @@ -0,0 +1,7 @@ +- title = capture do + This commit was signed with a <strong>verified</strong> signature and the + committer email is verified to belong to the same user. + +- locals = { signature: signature, title: title, label: 'Verified', css_class: 'valid', icon: 'icon_status_success_borderless', show_user: true } + += render partial: 'projects/commit/signature_badge', locals: locals diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 8b095f4ca10..483f28c74f2 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -1,7 +1,17 @@ +- @gfm_form = true + - content_for :note_actions do - if can?(current_user, :update_issue, @issue) = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' -#notes - = render 'shared/notes/notes_with_form', :autocomplete => true +%section.js-vue-notes-event + #js-vue-notes{ data: { discussions_path: discussions_project_issue_path(@project, @issue, format: :json), + register_path: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), + new_session_path: new_session_path(:user, redirect_to_referer: 'yes'), + markdown_docs_path: help_page_path('user/markdown'), + quick_actions_docs_path: help_page_path('user/project/quick_actions'), + notes_path: notes_url, + last_fetched_at: Time.now.to_i, + issue_data: serialize_issuable(@issue), + current_user_data: UserSerializer.new.represent(current_user).to_json } } diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml index 34d5a3e1831..6fb5aa45166 100644 --- a/app/views/projects/issues/_issues.html.haml +++ b/app/views/projects/issues/_issues.html.haml @@ -4,4 +4,4 @@ = render 'shared/empty_states/issues' - if @issues.present? - = paginate @issues, theme: "gitlab" + = paginate @issues, theme: "gitlab", total_pages: @total_pages diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index de0f1de057d..fd7ff176c5e 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -2,6 +2,11 @@ - page_title "#{@issue.title} (#{@issue.to_reference})", "Issues" - page_description @issue.description - page_card_attributes @issue.card_attributes + +- content_for :page_specific_javascripts do + = webpack_bundle_tag 'common_vue' + = webpack_bundle_tag 'notes' + - can_update_issue = can?(current_user, :update_issue, @issue) - can_report_spam = @issue.submittable_as_spam_by?(current_user) @@ -23,7 +28,7 @@ = icon('eye-slash', class: 'is-confidential') = issuable_meta(@issue, @project, "Issue") - .issuable-actions + .issuable-actions.js-issuable-actions .clearfix.issue-btn-group.dropdown %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } } Options @@ -36,8 +41,8 @@ - if @issue.author && current_user != @issue.author %li= link_to 'Report abuse', new_abuse_report_path(user_id: @issue.author.id, ref_url: issue_url(@issue)) - if can_update_issue - %li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' - %li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + %li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close js-btn-issue-action #{issue_button_visibility(@issue, true)}", title: 'Close issue' + %li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen js-btn-issue-action #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' - if can_report_spam %li= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' - if can_update_issue || can_report_spam @@ -74,7 +79,7 @@ .content-block.emoji-block .row - .col-sm-8 + .col-sm-8.js-issue-note-awards = render 'award_emoji/awards_block', awardable: @issue, inline: true .col-sm-4.new-branch-col = render 'new_branch' unless @issue.confidential? diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml index f5d5bc7eda9..43e23bb2200 100644 --- a/app/views/projects/jobs/_sidebar.html.haml +++ b/app/views/projects/jobs/_sidebar.html.haml @@ -46,14 +46,14 @@ %span.build-light-text Token: #{@build.trigger_request.trigger.short_token} - - if @build.trigger_request.variables + - if @build.trigger_variables.any? %p %button.btn.group.btn-group-justified.reveal-variables Reveal Variables %dl.js-build-variables.trigger-build-variables.hide - - @build.trigger_request.variables.each do |key, value| - %dt.js-build-variable.trigger-build-variable= key - %dd.js-build-value.trigger-build-value= value + - @build.trigger_variables.each do |trigger_variable| + %dt.js-build-variable.trigger-build-variable= trigger_variable[:key] + %dd.js-build-value.trigger-build-value= trigger_variable[:value] %div{ class: (@build.pipeline.stages_count > 1 ? "block" : "block-last") } %p diff --git a/app/views/projects/merge_requests/_merge_requests.html.haml b/app/views/projects/merge_requests/_merge_requests.html.haml index 4e97f74dd6a..bd6f1c05949 100644 --- a/app/views/projects/merge_requests/_merge_requests.html.haml +++ b/app/views/projects/merge_requests/_merge_requests.html.haml @@ -5,4 +5,4 @@ = render 'shared/empty_states/merge_requests' - if @merge_requests.present? - = paginate @merge_requests, theme: "gitlab" + = paginate @merge_requests, theme: "gitlab", total_pages: @total_pages diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index a2e819fb3a7..f3c44c94a5c 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -17,7 +17,7 @@ .issuable-meta = issuable_meta(@merge_request, @project, "Merge request") - .issuable-actions + .issuable-actions.js-issuable-actions .clearfix.issue-btn-group.dropdown %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } } Options diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index b04f5efe1f9..fb07141d2ac 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -31,7 +31,7 @@ %template{ 'v-if' => 'isResolved' } = render 'shared/icons/icon_status_success_solid.svg' %template{ 'v-else' => '' } - = render 'shared/icons/icon_status_success.svg' + = render 'shared/icons/icon_resolve_discussion.svg' - if current_user - if note.emoji_awardable? diff --git a/app/views/projects/runners/_form.html.haml b/app/views/projects/runners/_form.html.haml index 2ef1f98ba48..ac8e15a48b2 100644 --- a/app/views/projects/runners/_form.html.haml +++ b/app/views/projects/runners/_form.html.haml @@ -7,6 +7,12 @@ = f.check_box :active %span.light Paused Runners don't accept new jobs .form-group + = label :protected, "Protected", class: 'control-label' + .col-sm-10 + .checkbox + = f.check_box :access_level, {}, 'ref_protected', 'not_protected' + %span.light This runner will only run on pipelines trigged on protected branches + .form-group = label :run_untagged, 'Run untagged jobs', class: 'control-label' .col-sm-10 .checkbox diff --git a/app/views/projects/runners/show.html.haml b/app/views/projects/runners/show.html.haml index 49415ba557b..dfab04aa1fb 100644 --- a/app/views/projects/runners/show.html.haml +++ b/app/views/projects/runners/show.html.haml @@ -20,6 +20,9 @@ %td Active %td= @runner.active? ? 'Yes' : 'No' %tr + %td Protected + %td= @runner.ref_protected? ? 'Yes' : 'No' + %tr %td Can run untagged jobs %td= @runner.run_untagged? ? 'Yes' : 'No' %tr diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 8ded7440de3..23a418ad640 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -14,10 +14,10 @@ %ul %li = link_to_label(label, subject: subject, type: :merge_request) do - view merge requests + View merge requests %li = link_to_label(label, subject: subject) do - view open issues + View open issues - if current_user %li.label-subscription - if can_subscribe_to_label_in_different_levels?(label) diff --git a/app/views/shared/icons/_icon_arrow_right.svg.erb b/app/views/shared/icons/_icon_arrow_right.svg.erb new file mode 100644 index 00000000000..24d64eb73bd --- /dev/null +++ b/app/views/shared/icons/_icon_arrow_right.svg.erb @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M9 6H2a2 2 0 1 0 0 4h7v2.586a1 1 0 0 0 1.707.707l4.586-4.586a1 1 0 0 0 0-1.414l-4.586-4.586A1 1 0 0 0 9 3.414V6z"/></svg> diff --git a/app/views/shared/icons/_icon_resolve_discussion.svg b/app/views/shared/icons/_icon_resolve_discussion.svg new file mode 100644 index 00000000000..845562e9320 --- /dev/null +++ b/app/views/shared/icons/_icon_resolve_discussion.svg @@ -0,0 +1 @@ +<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill-rule="evenodd"/><path d="M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z"/></svg> diff --git a/app/views/shared/icons/_icon_status_success.svg b/app/views/shared/icons/_icon_status_success.svg index 845562e9320..eed5006bebe 100755 --- a/app/views/shared/icons/_icon_status_success.svg +++ b/app/views/shared/icons/_icon_status_success.svg @@ -1 +1 @@ -<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill-rule="evenodd"/><path d="M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z"/></svg> +<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><g fill-rule="evenodd"><path d="M0 7a7 7 0 1 1 14 0A7 7 0 0 1 0 7z"/><path d="M13 7A6 6 0 1 0 1 7a6 6 0 0 0 12 0z" fill="#FFF"/><path d="M6.278 7.697L5.045 6.464a.296.296 0 0 0-.42-.002l-.613.614a.298.298 0 0 0 .002.42l1.91 1.909a.5.5 0 0 0 .703.005l.265-.265L9.997 6.04a.291.291 0 0 0-.009-.408l-.614-.614a.29.29 0 0 0-.408-.009L6.278 7.697z"/></g></svg> diff --git a/app/views/shared/icons/_thumbs_up.svg b/app/views/shared/icons/_thumbs_up.svg new file mode 100644 index 00000000000..7267462418e --- /dev/null +++ b/app/views/shared/icons/_thumbs_up.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.33 5h5.282a2 2 0 0 1 1.963 2.38l-.563 2.905a3 3 0 0 1-.243.732l-1.104 2.286A3 3 0 0 1 10.964 15H7a3 3 0 0 1-3-3V5.7a2 2 0 0 1 .436-1.247l3.11-3.9A.632.632 0 0 1 8.486.5l.138.137a1 1 0 0 1 .28.87L8.33 5zM1 6h2v7H1a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"/></svg> diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml index f22b6c9a6c2..cb706d80f23 100644 --- a/app/views/shared/issuable/_close_reopen_button.html.haml +++ b/app/views/shared/issuable/_close_reopen_button.html.haml @@ -4,9 +4,9 @@ - if can_update && is_current_user = link_to "Close #{display_issuable_type}", close_issuable_url(issuable), method: button_method, - class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}" + class: "hidden-xs hidden-sm btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}" = link_to "Reopen #{display_issuable_type}", reopen_issuable_url(issuable), method: button_method, - class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}" + class: "hidden-xs hidden-sm btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}" - elsif can_update && !is_current_user = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable - elsif issuable.author diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml index daa05990ae9..d8144a39b23 100644 --- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml +++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml @@ -2,7 +2,7 @@ - button_action = issuable.closed? ? 'reopen' : 'close' - display_button_action = button_action.capitalize - button_responsive_class = 'hidden-xs hidden-sm' -- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button issuable-close-button" +- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button" - toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle" - button_method = issuable_close_reopen_button_method(issuable) diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index c016aa2abcd..bb02dfa0d3a 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -29,18 +29,6 @@ = render 'shared/issuable/form/metadata', issuable: issuable, form: form -- if issuable.can_move?(current_user) - %hr - .form-group - = label_tag :move_to_project_id, 'Move', class: 'control-label' - .col-sm-10 - .issuable-form-select-holder - = hidden_field_tag :move_to_project_id, nil, class: 'js-move-dropdown', data: { placeholder: 'Select project', projects_url: autocomplete_projects_path(project_id: @project.id), page_size: MoveToProjectFinder::PAGE_SIZE } - - %span{ data: { toggle: 'tooltip', placement: 'auto top' }, style: 'cursor: default', - title: 'Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location.' } - = icon('question-circle') - = render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form = render 'shared/issuable/form/merge_params', issuable: issuable diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index c3f25c9d255..b07bc45512f 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -34,7 +34,7 @@ Milestone = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' + = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value.hide-collapsed - if issuable.milestone = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_remaining_days(issuable.milestone), data: { container: "body", html: 1 } @@ -60,7 +60,7 @@ Due date = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) - = link_to 'Edit', '#', class: 'edit-link pull-right' + = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value.hide-collapsed %span.value-content - if issuable.due_date @@ -95,7 +95,7 @@ Labels = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' + = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value.issuable-show-labels.hide-collapsed{ class: ("has-labels" if selected_labels.any?) } - if selected_labels.any? - selected_labels.each do |label| @@ -141,5 +141,22 @@ %cite{ title: project_ref } = project_ref = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left") + - if current_user && issuable.can_move?(current_user) + .block.js-sidebar-move-issue-block + .sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body' }, title: 'Move issue' } + = custom_icon('icon_arrow_right') + .dropdown.sidebar-move-issue-dropdown.hide-collapsed + %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button', + data: { toggle: 'dropdown' } } + Move issue + .dropdown-menu.dropdown-menu-selectable + = dropdown_title('Move issue') + = dropdown_filter('Search project', search_id: 'sidebar-move-issue-dropdown-search') + = dropdown_content + = dropdown_loading + = dropdown_footer add_content_class: true do + %button.btn.btn-new.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ disabled: true } + Move + = icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon') %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable, can_edit_issuable).to_json.html_safe diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 57392cd7fbb..58782fa5f58 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -13,7 +13,7 @@ Assignee = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' + = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' - if !signed_in %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => "Toggle sidebar" } = sidebar_gutter_toggle_icon diff --git a/app/views/shared/issuable/form/_issue_assignee.html.haml b/app/views/shared/issuable/form/_issue_assignee.html.haml index 66091d95a91..9b2b6e572e7 100644 --- a/app/views/shared/issuable/form/_issue_assignee.html.haml +++ b/app/views/shared/issuable/form/_issue_assignee.html.haml @@ -11,7 +11,7 @@ Assignee = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' + = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value.hide-collapsed - if assignees.any? - assignees.each do |assignee| diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml index 18011d528a0..bf8613b0f0d 100644 --- a/app/views/shared/issuable/form/_merge_request_assignee.html.haml +++ b/app/views/shared/issuable/form/_merge_request_assignee.html.haml @@ -9,7 +9,7 @@ Assignee = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to 'Edit', '#', class: 'edit-link pull-right' + = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value.hide-collapsed - if merge_request.assignee = link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 40379f48393..f03e0ab154c 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -21,7 +21,7 @@ .title Start date - if @project && can?(current_user, :admin_milestone, @project) - = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'edit-link pull-right' + = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value %span.value-content - if milestone.start_date @@ -51,7 +51,7 @@ .title.hide-collapsed Due date - if @project && can?(current_user, :admin_milestone, @project) - = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'edit-link pull-right' + = link_to 'Edit', edit_project_milestone_path(@project, @milestone), class: 'js-sidebar-dropdown-toggle edit-link pull-right' .value.hide-collapsed %span.value-content - if milestone.due_date @@ -88,7 +88,7 @@ .block.merge-requests .sidebar-collapsed-icon %strong - = icon('exclamation', 'aria-hidden': 'true') + = custom_icon('mr_bold') %span= milestone.merge_requests.count .title.hide-collapsed Merge requests diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index eae04c9bbb8..e3e86709b8f 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -17,9 +17,9 @@ - elsif !current_user .disabled-comment.text-center.prepend-top-default Please - = link_to "register", new_session_path(:user, redirect_to_referer: 'yes') + = link_to "register", new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), class: 'js-register-link' or - = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes') + = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link' to comment %script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_gpg_signature_worker.rb index f34dff2d656..9b5ff17aafa 100644 --- a/app/workers/create_gpg_signature_worker.rb +++ b/app/workers/create_gpg_signature_worker.rb @@ -6,7 +6,11 @@ class CreateGpgSignatureWorker project = Project.find_by(id: project_id) return unless project + commit = project.commit(commit_sha) + + return unless commit + # This calculates and caches the signature in the database - Gitlab::Gpg::Commit.new(project, commit_sha).signature + Gitlab::Gpg::Commit.new(commit).signature end end diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb index 8b0cfcc8af8..269776a1f62 100644 --- a/app/workers/stuck_ci_jobs_worker.rb +++ b/app/workers/stuck_ci_jobs_worker.rb @@ -53,7 +53,7 @@ class StuckCiJobsWorker def drop_build(type, build, status, timeout) Rails.logger.info "#{self.class}: Dropping #{type} build #{build.id} for runner #{build.runner_id} (status: #{status}, timeout: #{timeout})" Gitlab::OptimisticLocking.retry_lock(build, 3) do |b| - b.drop + b.drop(:stuck_or_timeout_failure) end end end diff --git a/changelogs/unreleased/17849-allow-admin-to-restrict-min-key-length-and-techno.yml b/changelogs/unreleased/17849-allow-admin-to-restrict-min-key-length-and-techno.yml new file mode 100644 index 00000000000..8ec78bbd41f --- /dev/null +++ b/changelogs/unreleased/17849-allow-admin-to-restrict-min-key-length-and-techno.yml @@ -0,0 +1,5 @@ +--- +title: Add settings for minimum SSH key strength and allowed key type +merge_request: 13712 +author: Cory Hinshaw +type: added diff --git a/changelogs/unreleased/30162-retire-koding-integration.yml b/changelogs/unreleased/30162-retire-koding-integration.yml new file mode 100644 index 00000000000..63c2b9eb161 --- /dev/null +++ b/changelogs/unreleased/30162-retire-koding-integration.yml @@ -0,0 +1,4 @@ +--- +title: Deprecation of Koding integration, removal of setting in Admin Panel +merge_request: 13992 +author: @mydigitalself diff --git a/changelogs/unreleased/34261-move-move-to-sidebar.yml b/changelogs/unreleased/34261-move-move-to-sidebar.yml new file mode 100644 index 00000000000..59fa1d4c221 --- /dev/null +++ b/changelogs/unreleased/34261-move-move-to-sidebar.yml @@ -0,0 +1,5 @@ +--- +title: Move "Move issue" controls to right-sidebar +merge_request: +author: +type: changed diff --git a/changelogs/unreleased/35010-projects-nav-dropdown.yml b/changelogs/unreleased/35010-projects-nav-dropdown.yml new file mode 100644 index 00000000000..c5bed723f55 --- /dev/null +++ b/changelogs/unreleased/35010-projects-nav-dropdown.yml @@ -0,0 +1,5 @@ +--- +title: Add dropdown to Projects nav item +merge_request: 13866 +author: +type: added diff --git a/changelogs/unreleased/35010-remove-goto-project-from-breadcrumb.yml b/changelogs/unreleased/35010-remove-goto-project-from-breadcrumb.yml new file mode 100644 index 00000000000..6cd7f4e9cc6 --- /dev/null +++ b/changelogs/unreleased/35010-remove-goto-project-from-breadcrumb.yml @@ -0,0 +1,5 @@ +--- +title: Remove project select dropdown from breadcrumb +merge_request: 14010 +author: +type: changed diff --git a/changelogs/unreleased/35686-unescape-wiki-title.yml b/changelogs/unreleased/35686-unescape-wiki-title.yml new file mode 100644 index 00000000000..4b2b7078163 --- /dev/null +++ b/changelogs/unreleased/35686-unescape-wiki-title.yml @@ -0,0 +1,5 @@ +--- +title: Unescape HTML characters in Wiki title +merge_request: 13942 +author: Jacopo Beschi @jacopo-beschi +type: fixed diff --git a/changelogs/unreleased/36061-mr-ref.yml b/changelogs/unreleased/36061-mr-ref.yml deleted file mode 100644 index 039666070a7..00000000000 --- a/changelogs/unreleased/36061-mr-ref.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Instrument MergeRequest#ensure_ref_fetched -merge_request: -author: -type: other diff --git a/changelogs/unreleased/36582-fix-note-resolved-icon.yml b/changelogs/unreleased/36582-fix-note-resolved-icon.yml deleted file mode 100644 index 758c0ecd212..00000000000 --- a/changelogs/unreleased/36582-fix-note-resolved-icon.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Update and fix resolvable note icons for easier recognition -merge_request: -author: -type: changed diff --git a/changelogs/unreleased/36821-fix-new-nav-wrapping-caret-and-increasing-height.yml b/changelogs/unreleased/36821-fix-new-nav-wrapping-caret-and-increasing-height.yml new file mode 100644 index 00000000000..54c7a8c8788 --- /dev/null +++ b/changelogs/unreleased/36821-fix-new-nav-wrapping-caret-and-increasing-height.yml @@ -0,0 +1,5 @@ +--- +title: Fix new navigation wrapping and causing height to grow +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/36860-deleted-user-fix.yml b/changelogs/unreleased/36860-deleted-user-fix.yml deleted file mode 100644 index 79e75441134..00000000000 --- a/changelogs/unreleased/36860-deleted-user-fix.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix failure when issue is authored by a deleted user -merge_request: 13807 -author: -type: fixed diff --git a/changelogs/unreleased/37204-deprecate-git-user-manual-ssh-config.yml b/changelogs/unreleased/37204-deprecate-git-user-manual-ssh-config.yml new file mode 100644 index 00000000000..593e74593c4 --- /dev/null +++ b/changelogs/unreleased/37204-deprecate-git-user-manual-ssh-config.yml @@ -0,0 +1,5 @@ +--- +title: Deprecate custom SSH client configuration for the git user +merge_request: 13930 +author: +type: deprecated diff --git a/changelogs/unreleased/37331-button-MR-widget.yml b/changelogs/unreleased/37331-button-MR-widget.yml new file mode 100644 index 00000000000..59bc1bd201e --- /dev/null +++ b/changelogs/unreleased/37331-button-MR-widget.yml @@ -0,0 +1,5 @@ +--- +title: Fix buttons with different height in merge request widget +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/37406-success-status-icon.yml b/changelogs/unreleased/37406-success-status-icon.yml new file mode 100644 index 00000000000..faac947f188 --- /dev/null +++ b/changelogs/unreleased/37406-success-status-icon.yml @@ -0,0 +1,5 @@ +--- +title: Fix broken svg in jobs dropdown for success status +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/add_message_to_the_404_page.yml b/changelogs/unreleased/add_message_to_the_404_page.yml new file mode 100644 index 00000000000..f567796fe9f --- /dev/null +++ b/changelogs/unreleased/add_message_to_the_404_page.yml @@ -0,0 +1,5 @@ +--- +title: Changed message and title on the 404 page +merge_request: +author: Branka Martinovic +type: added diff --git a/changelogs/unreleased/additional-time-series-charts.yml b/changelogs/unreleased/additional-time-series-charts.yml new file mode 100644 index 00000000000..80c1af54881 --- /dev/null +++ b/changelogs/unreleased/additional-time-series-charts.yml @@ -0,0 +1,5 @@ +--- +title: Added support the multiple time series for prometheus monitoring +merge_request: !36893 +author: +type: changed diff --git a/changelogs/unreleased/api-gpg-key-management.yml b/changelogs/unreleased/api-gpg-key-management.yml new file mode 100644 index 00000000000..0be35a5823b --- /dev/null +++ b/changelogs/unreleased/api-gpg-key-management.yml @@ -0,0 +1,5 @@ +--- +title: 'API: Add GPG key management' +merge_request: 13828 +author: Robert Schilling +type: added diff --git a/changelogs/unreleased/api_branches_head.yml b/changelogs/unreleased/api_branches_head.yml new file mode 100644 index 00000000000..68d8d3d5168 --- /dev/null +++ b/changelogs/unreleased/api_branches_head.yml @@ -0,0 +1,5 @@ +--- +title: Add branch existence check to the APIv4 branches via HEAD request +merge_request: 13979 +author: Vitaliy @blackst0ne Klachkov +type: added diff --git a/changelogs/unreleased/bugfix-notify-custom-participants.yml b/changelogs/unreleased/bugfix-notify-custom-participants.yml deleted file mode 100644 index 04fcb95e18a..00000000000 --- a/changelogs/unreleased/bugfix-notify-custom-participants.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fixed: Notifications weren't sending to participating users with a `Custom` notification setting. -merge_request: 13680 -author: jneen -type: fixed diff --git a/changelogs/unreleased/bvl-validate-po-files.yml b/changelogs/unreleased/bvl-validate-po-files.yml new file mode 100644 index 00000000000..f840b2c3973 --- /dev/null +++ b/changelogs/unreleased/bvl-validate-po-files.yml @@ -0,0 +1,4 @@ +--- +title: Validate PO-files in static analysis +merge_request: 13000 +author: diff --git a/changelogs/unreleased/changes-bar-sticky-fix.yml b/changelogs/unreleased/changes-bar-sticky-fix.yml deleted file mode 100644 index 7d62773ef7a..00000000000 --- a/changelogs/unreleased/changes-bar-sticky-fix.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fixed diff changes bar buttons from showing/hiding whilst scrolling -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/docs-fix-15669-issue-move-api.yml b/changelogs/unreleased/docs-fix-15669-issue-move-api.yml new file mode 100644 index 00000000000..db68428fda3 --- /dev/null +++ b/changelogs/unreleased/docs-fix-15669-issue-move-api.yml @@ -0,0 +1,5 @@ +--- +title: Add to_project_id parameter to Move Issue via API example +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/dont-remove-add-diff-btn-on-post.yml b/changelogs/unreleased/dont-remove-add-diff-btn-on-post.yml new file mode 100644 index 00000000000..a7db18dbd60 --- /dev/null +++ b/changelogs/unreleased/dont-remove-add-diff-btn-on-post.yml @@ -0,0 +1,5 @@ +--- +title: Fixed add diff note button not showing after deleting a comment +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/feature-dependency-status-badge.yml b/changelogs/unreleased/feature-dependency-status-badge.yml new file mode 100644 index 00000000000..1becff3585a --- /dev/null +++ b/changelogs/unreleased/feature-dependency-status-badge.yml @@ -0,0 +1,5 @@ +--- +title: Add badge for dependency status +merge_request: 13588 +author: Markus Koller +type: other diff --git a/changelogs/unreleased/feature-gb-kubernetes-only-pipeline-jobs.yml b/changelogs/unreleased/feature-gb-kubernetes-only-pipeline-jobs.yml new file mode 100644 index 00000000000..00c38a0c671 --- /dev/null +++ b/changelogs/unreleased/feature-gb-kubernetes-only-pipeline-jobs.yml @@ -0,0 +1,5 @@ +--- +title: Add CI/CD active kubernetes job policy +merge_request: 13849 +author: +type: added diff --git a/changelogs/unreleased/feature-gpg-verification-status.yml b/changelogs/unreleased/feature-gpg-verification-status.yml new file mode 100644 index 00000000000..7518fafcdb8 --- /dev/null +++ b/changelogs/unreleased/feature-gpg-verification-status.yml @@ -0,0 +1,6 @@ +--- +title: 'Update the GPG verification semantics: A GPG signature must additionally match + the committer in order to be verified' +merge_request: 13771 +author: Alexis Reigel +type: changed diff --git a/changelogs/unreleased/feature-sm-33281-protected-runner-executes-jobs-on-protected-branch.yml b/changelogs/unreleased/feature-sm-33281-protected-runner-executes-jobs-on-protected-branch.yml new file mode 100644 index 00000000000..b57b9a3dfbe --- /dev/null +++ b/changelogs/unreleased/feature-sm-33281-protected-runner-executes-jobs-on-protected-branch.yml @@ -0,0 +1,5 @@ +--- +title: Protected runners +merge_request: 13194 +author: +type: added diff --git a/changelogs/unreleased/feature-sm-34518-extend-api-pipeline-schedule-variable-new.yml b/changelogs/unreleased/feature-sm-34518-extend-api-pipeline-schedule-variable-new.yml new file mode 100644 index 00000000000..969a5aeaed3 --- /dev/null +++ b/changelogs/unreleased/feature-sm-34518-extend-api-pipeline-schedule-variable-new.yml @@ -0,0 +1,5 @@ +--- +title: 'Extend API: Pipeline Schedule Variable' +merge_request: 13653 +author: +type: added diff --git a/changelogs/unreleased/feature-sm-37239-implement-failure_reason-on-ci_builds.yml b/changelogs/unreleased/feature-sm-37239-implement-failure_reason-on-ci_builds.yml new file mode 100644 index 00000000000..006b0b45844 --- /dev/null +++ b/changelogs/unreleased/feature-sm-37239-implement-failure_reason-on-ci_builds.yml @@ -0,0 +1,5 @@ +--- +title: Implement `failure_reason` on `ci_builds` +merge_request: 13937 +author: +type: added diff --git a/changelogs/unreleased/fix-import-events.yml b/changelogs/unreleased/fix-import-events.yml deleted file mode 100644 index 84b4410a019..00000000000 --- a/changelogs/unreleased/fix-import-events.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix events error importing GitLab projects -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/fix-sm-37040-regression-pipeline-trigger-via-api-fails-with-500-internal-server-error-in-9-5-1.yml b/changelogs/unreleased/fix-sm-37040-regression-pipeline-trigger-via-api-fails-with-500-internal-server-error-in-9-5-1.yml deleted file mode 100644 index fb97bdb6b30..00000000000 --- a/changelogs/unreleased/fix-sm-37040-regression-pipeline-trigger-via-api-fails-with-500-internal-server-error-in-9-5-1.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix pipeline trigger via API fails with 500 Internal Server Error in 9.5 -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/fix_typo_in_deploy_keys_docs.yml b/changelogs/unreleased/fix_typo_in_deploy_keys_docs.yml new file mode 100644 index 00000000000..fa50e36e28a --- /dev/null +++ b/changelogs/unreleased/fix_typo_in_deploy_keys_docs.yml @@ -0,0 +1,5 @@ +--- +title: Fix typo in the API Deploy Keys documentation page +merge_request: 14014 +author: Vitaliy @blackst0ne Klachkov +type: fixed diff --git a/changelogs/unreleased/fuzzy-issue-search.yml b/changelogs/unreleased/fuzzy-issue-search.yml new file mode 100644 index 00000000000..8195e97ed59 --- /dev/null +++ b/changelogs/unreleased/fuzzy-issue-search.yml @@ -0,0 +1,5 @@ +--- +title: Support a multi-word fuzzy seach issues/merge requests on search bar +merge_request: 13780 +author: Hiroyuki Sato +type: changed diff --git a/changelogs/unreleased/gitaly-post-upload-pack-mandatory.yml b/changelogs/unreleased/gitaly-post-upload-pack-mandatory.yml new file mode 100644 index 00000000000..edf11484d1f --- /dev/null +++ b/changelogs/unreleased/gitaly-post-upload-pack-mandatory.yml @@ -0,0 +1,5 @@ +--- +title: Make Gitaly PostUploadPack mandatory +merge_request: 13953 +author: +type: changed diff --git a/changelogs/unreleased/issue-api-my-reaction.yml b/changelogs/unreleased/issue-api-my-reaction.yml new file mode 100644 index 00000000000..1c12478fbc0 --- /dev/null +++ b/changelogs/unreleased/issue-api-my-reaction.yml @@ -0,0 +1,5 @@ +--- +title: Add my_reaction_emoji param to /issues and /merge_requests API +merge_request: 14016 +author: Hiroyuki Sato +type: added diff --git a/changelogs/unreleased/mk-fix-user-namespace-rename.yml b/changelogs/unreleased/mk-fix-user-namespace-rename.yml deleted file mode 100644 index bb43b21f708..00000000000 --- a/changelogs/unreleased/mk-fix-user-namespace-rename.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Make username update fail if the namespace update fails -merge_request: 13642 -author: -type: fixed diff --git a/changelogs/unreleased/move-action.yml b/changelogs/unreleased/move-action.yml new file mode 100644 index 00000000000..65eceae3ef9 --- /dev/null +++ b/changelogs/unreleased/move-action.yml @@ -0,0 +1,4 @@ +--- +title: Allow users to move issues to other projects using a / command +merge_request: 13436 +author: Manolis Mavrofidis diff --git a/changelogs/unreleased/mr-index-eager-load.yml b/changelogs/unreleased/mr-index-eager-load.yml deleted file mode 100644 index 11c33055b17..00000000000 --- a/changelogs/unreleased/mr-index-eager-load.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Eager load head pipeline projects for MRs index -merge_request: -author: -type: other diff --git a/changelogs/unreleased/mr-index-page-performance.yml b/changelogs/unreleased/mr-index-page-performance.yml new file mode 100644 index 00000000000..df5f44c04fa --- /dev/null +++ b/changelogs/unreleased/mr-index-page-performance.yml @@ -0,0 +1,5 @@ +--- +title: Re-use issue/MR counts for the pagination system +merge_request: +author: +type: other diff --git a/changelogs/unreleased/revert-appearances-description-html-not-null.yml b/changelogs/unreleased/revert-appearances-description-html-not-null.yml deleted file mode 100644 index 4e3c39cb5fd..00000000000 --- a/changelogs/unreleased/revert-appearances-description-html-not-null.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Re-allow appearances.description_html to be NULL -merge_request: -author: -type: fixed diff --git a/changelogs/unreleased/rouge-2-2-1.yml b/changelogs/unreleased/rouge-2-2-1.yml new file mode 100644 index 00000000000..2d8879e5574 --- /dev/null +++ b/changelogs/unreleased/rouge-2-2-1.yml @@ -0,0 +1,5 @@ +--- +title: Bump rouge to v2.2.1 +merge_request: 13887 +author: +type: other diff --git a/changelogs/unreleased/sh-filter-csrf-params.yml b/changelogs/unreleased/sh-filter-csrf-params.yml deleted file mode 100644 index 70eb3321e77..00000000000 --- a/changelogs/unreleased/sh-filter-csrf-params.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Filter additional secrets from Rails logs -merge_request: -author: -type: security diff --git a/changelogs/unreleased/sm-cherry-pick-list-commits-in-message.yml b/changelogs/unreleased/sm-cherry-pick-list-commits-in-message.yml new file mode 100644 index 00000000000..602ca358b8b --- /dev/null +++ b/changelogs/unreleased/sm-cherry-pick-list-commits-in-message.yml @@ -0,0 +1,5 @@ +--- +title: Add 'from commit' information to cherry-picked commits +merge_request: 13475 +author: Saverio Miroddi +type: added diff --git a/changelogs/unreleased/zj-sort-templates.yml b/changelogs/unreleased/zj-sort-templates.yml new file mode 100644 index 00000000000..443c4355890 --- /dev/null +++ b/changelogs/unreleased/zj-sort-templates.yml @@ -0,0 +1,5 @@ +--- +title: Sort templates in the dropdown +merge_request: +author: +type: fixed diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml index ca5b941aebf..d6c3c84851b 100644 --- a/config/dependency_decisions.yml +++ b/config/dependency_decisions.yml @@ -404,3 +404,15 @@ :why: https://github.com/mafintosh/thunky/blob/master/README.md#license :versions: [] :when: 2017-08-07 05:56:09.907045000 Z +- - :whitelist + - Unlicense + - :who: Nick Thomas <nick@gitlab.com> + :why: https://gitlab.com/gitlab-com/organization/issues/116 + :versions: [] + :when: 2017-09-01 17:17:51.996511844 Z +- - :blacklist + - Facebook BSD+PATENTS + - :who: Nick Thomas <nick@gitlab.com> + :why: https://gitlab.com/gitlab-com/organization/issues/117 + :versions: [] + :when: 2017-09-04 12:59:51.150798717 Z diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 545c01e1156..c5704ac5857 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -508,7 +508,7 @@ production: &base failure_count_threshold: 10 # number of failures before stopping attempts failure_wait_time: 30 # Seconds after an access failure before allowing access again failure_reset_time: 1800 # Time in seconds to expire failures - storage_timeout: 5 # Time in seconds to wait before aborting a storage access attempt + storage_timeout: 30 # Time in seconds to wait before aborting a storage access attempt ## Backup settings diff --git a/config/initializers/8_metrics.rb b/config/initializers/8_metrics.rb index 370a976b64a..5b455a8065a 100644 --- a/config/initializers/8_metrics.rb +++ b/config/initializers/8_metrics.rb @@ -122,6 +122,7 @@ def instrument_classes(instrumentation) # Needed for https://gitlab.com/gitlab-org/gitlab-ce/issues/36061 instrumentation.instrument_instance_method(MergeRequest, :ensure_ref_fetched) + instrumentation.instrument_instance_method(MergeRequest, :fetch_ref) end # rubocop:enable Metrics/AbcSize diff --git a/config/initializers/fast_gettext.rb b/config/initializers/fast_gettext.rb index eb589ecdb52..fd0167aa476 100644 --- a/config/initializers/fast_gettext.rb +++ b/config/initializers/fast_gettext.rb @@ -1,4 +1,7 @@ -FastGettext.add_text_domain 'gitlab', path: File.join(Rails.root, 'locale'), type: :po +FastGettext.add_text_domain 'gitlab', + path: File.join(Rails.root, 'locale'), + type: :po, + ignore_fuzzy: true FastGettext.default_text_domain = 'gitlab' FastGettext.default_available_locales = Gitlab::I18n.available_locales FastGettext.default_locale = :en diff --git a/config/routes/project.rb b/config/routes/project.rb index 06928c7b9ce..a15e7f8a344 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -303,11 +303,13 @@ constraints(ProjectUrlConstrainer.new) do member do post :toggle_subscription post :mark_as_spam + post :move get :referenced_merge_requests get :related_branches get :can_create_branch get :realtime_changes post :create_merge_request + get :discussions, format: :json end collection do post :bulk_update diff --git a/config/webpack.config.js b/config/webpack.config.js index 7d63a42d7d8..6b0cd023291 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -30,7 +30,7 @@ var config = { blob: './blob_edit/blob_bundle.js', boards: './boards/boards_bundle.js', common: './commons/index.js', - common_vue: ['vue', './vue_shared/common_vue.js'], + common_vue: './vue_shared/vue_resource_interceptor.js', common_d3: ['d3'], cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js', commit_pipelines: './commit/pipelines/pipelines_bundle.js', @@ -55,6 +55,7 @@ var config = { monitoring: './monitoring/monitoring_bundle.js', network: './network/network_bundle.js', notebook_viewer: './blob/notebook_viewer.js', + notes: './notes/index.js', pdf_viewer: './blob/pdf_viewer.js', pipelines: './pipelines/pipelines_bundle.js', pipelines_charts: './pipelines/pipelines_charts.js', @@ -194,6 +195,7 @@ var config = { 'merge_conflicts', 'monitoring', 'notebook_viewer', + 'notes', 'pdf_viewer', 'pipelines', 'pipelines_details', diff --git a/db/migrate/20161020180657_add_minimum_key_length_to_application_settings.rb b/db/migrate/20161020180657_add_minimum_key_length_to_application_settings.rb new file mode 100644 index 00000000000..5b6079002c0 --- /dev/null +++ b/db/migrate/20161020180657_add_minimum_key_length_to_application_settings.rb @@ -0,0 +1,29 @@ +class AddMinimumKeyLengthToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def up + # A key restriction has these possible states: + # + # * -1 means "this key type is completely disabled" + # * 0 means "all keys of this type are valid" + # * > 0 means "keys must have at least this many bits to be valid" + # + # The default is 0, for backward compatibility + add_column_with_default :application_settings, :rsa_key_restriction, :integer, default: 0 + add_column_with_default :application_settings, :dsa_key_restriction, :integer, default: 0 + add_column_with_default :application_settings, :ecdsa_key_restriction, :integer, default: 0 + add_column_with_default :application_settings, :ed25519_key_restriction, :integer, default: 0 + end + + def down + remove_column :application_settings, :rsa_key_restriction + remove_column :application_settings, :dsa_key_restriction + remove_column :application_settings, :ecdsa_key_restriction + remove_column :application_settings, :ed25519_key_restriction + end +end diff --git a/db/migrate/20170816133938_add_access_level_to_ci_runners.rb b/db/migrate/20170816133938_add_access_level_to_ci_runners.rb new file mode 100644 index 00000000000..fc484730f42 --- /dev/null +++ b/db/migrate/20170816133938_add_access_level_to_ci_runners.rb @@ -0,0 +1,16 @@ +class AddAccessLevelToCiRunners < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:ci_runners, :access_level, :integer, + default: Ci::Runner.access_levels['not_protected']) + end + + def down + remove_column(:ci_runners, :access_level) + end +end diff --git a/db/migrate/20170816133940_add_protected_to_ci_builds.rb b/db/migrate/20170816133940_add_protected_to_ci_builds.rb new file mode 100644 index 00000000000..c73a4387d29 --- /dev/null +++ b/db/migrate/20170816133940_add_protected_to_ci_builds.rb @@ -0,0 +1,7 @@ +class AddProtectedToCiBuilds < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :ci_builds, :protected, :boolean + end +end diff --git a/db/migrate/20170816143940_add_protected_to_ci_pipelines.rb b/db/migrate/20170816143940_add_protected_to_ci_pipelines.rb new file mode 100644 index 00000000000..ce8f1e03686 --- /dev/null +++ b/db/migrate/20170816143940_add_protected_to_ci_pipelines.rb @@ -0,0 +1,7 @@ +class AddProtectedToCiPipelines < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :ci_pipelines, :protected, :boolean + end +end diff --git a/db/migrate/20170816153940_add_index_on_ci_builds_protected.rb b/db/migrate/20170816153940_add_index_on_ci_builds_protected.rb new file mode 100644 index 00000000000..caf7c705a6e --- /dev/null +++ b/db/migrate/20170816153940_add_index_on_ci_builds_protected.rb @@ -0,0 +1,15 @@ +class AddIndexOnCiBuildsProtected < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :ci_builds, :protected + end + + def down + remove_concurrent_index :ci_builds, :protected if index_exists?(:ci_builds, :protected) + end +end diff --git a/db/migrate/20170817123339_add_verification_status_to_gpg_signatures.rb b/db/migrate/20170817123339_add_verification_status_to_gpg_signatures.rb new file mode 100644 index 00000000000..128cd109f8d --- /dev/null +++ b/db/migrate/20170817123339_add_verification_status_to_gpg_signatures.rb @@ -0,0 +1,20 @@ +class AddVerificationStatusToGpgSignatures < ActiveRecord::Migration + DOWNTIME = false + + include Gitlab::Database::MigrationHelpers + disable_ddl_transaction! + + def up + # First we remove all signatures because we need to re-verify them all + # again anyway (because of the updated verification logic). + # + # This makes adding the column with default values faster + truncate(:gpg_signatures) + + add_column_with_default(:gpg_signatures, :verification_status, :smallint, default: 0) + end + + def down + remove_column(:gpg_signatures, :verification_status) + end +end diff --git a/db/migrate/20170830125940_add_failure_reason_to_ci_builds.rb b/db/migrate/20170830125940_add_failure_reason_to_ci_builds.rb new file mode 100644 index 00000000000..5a7487b9227 --- /dev/null +++ b/db/migrate/20170830125940_add_failure_reason_to_ci_builds.rb @@ -0,0 +1,9 @@ +class AddFailureReasonToCiBuilds < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_builds, :failure_reason, :integer + end +end diff --git a/db/post_migrate/20170830084744_destroy_gpg_signatures.rb b/db/post_migrate/20170830084744_destroy_gpg_signatures.rb new file mode 100644 index 00000000000..b04d36f6537 --- /dev/null +++ b/db/post_migrate/20170830084744_destroy_gpg_signatures.rb @@ -0,0 +1,10 @@ +class DestroyGpgSignatures < ActiveRecord::Migration + DOWNTIME = false + + def up + truncate(:gpg_signatures) + end + + def down + end +end diff --git a/db/post_migrate/20170831195038_remove_valid_signature_from_gpg_signatures.rb b/db/post_migrate/20170831195038_remove_valid_signature_from_gpg_signatures.rb new file mode 100644 index 00000000000..9b6745e33d9 --- /dev/null +++ b/db/post_migrate/20170831195038_remove_valid_signature_from_gpg_signatures.rb @@ -0,0 +1,11 @@ +class RemoveValidSignatureFromGpgSignatures < ActiveRecord::Migration + DOWNTIME = false + + def up + remove_column :gpg_signatures, :valid_signature + end + + def down + add_column :gpg_signatures, :valid_signature, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index 0f4b0c0c3b3..40b84f2bddd 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: 20170824162758) do +ActiveRecord::Schema.define(version: 20170831195038) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -129,6 +129,10 @@ ActiveRecord::Schema.define(version: 20170824162758) do t.boolean "password_authentication_enabled" t.boolean "project_export_enabled", default: true, null: false t.boolean "hashed_storage_enabled", default: false, null: false + t.integer "rsa_key_restriction", default: 0, null: false + t.integer "dsa_key_restriction", default: 0, null: false + t.integer "ecdsa_key_restriction", default: 0, null: false + t.integer "ed25519_key_restriction", default: 0, null: false end create_table "audit_events", force: :cascade do |t| @@ -242,6 +246,8 @@ ActiveRecord::Schema.define(version: 20170824162758) do t.integer "auto_canceled_by_id" t.boolean "retried" t.integer "stage_id" + t.boolean "protected" + t.integer "failure_reason" end add_index "ci_builds", ["auto_canceled_by_id"], name: "index_ci_builds_on_auto_canceled_by_id", using: :btree @@ -250,6 +256,7 @@ ActiveRecord::Schema.define(version: 20170824162758) do add_index "ci_builds", ["commit_id", "type", "name", "ref"], name: "index_ci_builds_on_commit_id_and_type_and_name_and_ref", using: :btree add_index "ci_builds", ["commit_id", "type", "ref"], name: "index_ci_builds_on_commit_id_and_type_and_ref", using: :btree add_index "ci_builds", ["project_id"], name: "index_ci_builds_on_project_id", using: :btree + add_index "ci_builds", ["protected"], name: "index_ci_builds_on_protected", using: :btree add_index "ci_builds", ["runner_id"], name: "index_ci_builds_on_runner_id", using: :btree add_index "ci_builds", ["stage_id"], name: "index_ci_builds_on_stage_id", using: :btree add_index "ci_builds", ["status", "type", "runner_id"], name: "index_ci_builds_on_status_and_type_and_runner_id", using: :btree @@ -332,6 +339,7 @@ ActiveRecord::Schema.define(version: 20170824162758) do t.integer "auto_canceled_by_id" t.integer "pipeline_schedule_id" t.integer "source" + t.boolean "protected" end add_index "ci_pipelines", ["auto_canceled_by_id"], name: "index_ci_pipelines_on_auto_canceled_by_id", using: :btree @@ -367,6 +375,7 @@ ActiveRecord::Schema.define(version: 20170824162758) do t.string "architecture" t.boolean "run_untagged", default: true, null: false t.boolean "locked", default: false, null: false + t.integer "access_level", default: 0, null: false end add_index "ci_runners", ["contacted_at"], name: "index_ci_runners_on_contacted_at", using: :btree @@ -600,11 +609,11 @@ ActiveRecord::Schema.define(version: 20170824162758) do t.datetime "updated_at", null: false t.integer "project_id" t.integer "gpg_key_id" - t.boolean "valid_signature" t.binary "commit_sha" t.binary "gpg_key_primary_keyid" t.text "gpg_key_user_name" t.text "gpg_key_user_email" + t.integer "verification_status", limit: 2, default: 0, null: false end add_index "gpg_signatures", ["commit_sha"], name: "index_gpg_signatures_on_commit_sha", unique: true, using: :btree diff --git a/doc/README.md b/doc/README.md index 63ba8ff03e9..b250fa08382 100644 --- a/doc/README.md +++ b/doc/README.md @@ -160,7 +160,6 @@ have access to GitLab administration tools and settings. ### Integrations - [Integrations](integration/README.md): How to integrate with systems such as JIRA, Redmine, Twitter. -- [Koding](administration/integration/koding.md): Set up Koding to use with GitLab. - [Mattermost](user/project/integrations/mattermost.md): Set up GitLab with Mattermost. ### Monitoring diff --git a/doc/administration/integration/koding.md b/doc/administration/integration/koding.md index b95c425842c..67f9f01efb8 100644 --- a/doc/administration/integration/koding.md +++ b/doc/administration/integration/koding.md @@ -1,6 +1,10 @@ # Koding & GitLab -> [Introduced][ce-5909] in GitLab 8.11. +>**Notes:** +- **As of GitLab 10.0, the Koding integration is deprecated and will be removed + in a future version. The option to configure it is removed from GitLab's admin + area.** +- [Introduced][ce-5909] in GitLab 8.11. This document will guide you through installing and configuring Koding with GitLab. diff --git a/doc/api/deploy_keys.md b/doc/api/deploy_keys.md index 4fa800ecb9c..273d5a56b6f 100644 --- a/doc/api/deploy_keys.md +++ b/doc/api/deploy_keys.md @@ -106,7 +106,7 @@ Example response: Creates a new deploy key for a project. If the deploy key already exists in another project, it will be joined to current -project only if original one was is accessible by the same user. +project only if original one is accessible by the same user. ``` POST /projects/:id/deploy_keys diff --git a/doc/api/issues.md b/doc/api/issues.md index 14635114a31..8ca66049d31 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -30,20 +30,22 @@ GET /issues?milestone=1.0.0&state=opened GET /issues?iids[]=42&iids[]=43 GET /issues?author_id=5 GET /issues?assignee_id=5 -``` - -| Attribute | Type | Required | Description | -|-------------|----------------|----------|-----------------------------------------------------------------------------------------------------------------------------| -| `state` | string | no | Return all issues or just those that are `opened` or `closed` | -| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels | -| `milestone` | string | no | The milestone title | -| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all`. Defaults to `created-by-me` _([Introduced][ce-13004] in GitLab 9.5)_ | -| `author_id` | integer | no | Return issues created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me`. _([Introduced][ce-13004] in GitLab 9.5)_ | -| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | -| `iids[]` | Array[integer] | no | Return only the issues having the given `iid` | -| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | -| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | -| `search` | string | no | Search issues against their `title` and `description` | +GET /issues?my_reaction_emoji=star +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `state` | string | no | Return all issues or just those that are `opened` or `closed` | +| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels | +| `milestone` | string | no | The milestone title | +| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all`. Defaults to `created-by-me` _([Introduced][ce-13004] in GitLab 9.5)_ | +| `author_id` | integer | no | Return issues created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me`. _([Introduced][ce-13004] in GitLab 9.5)_ | +| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | +| `my_reaction_emoji` | string | no | Return issues reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ | +| `iids[]` | Array[integer] | no | Return only the issues having the given `iid` | +| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | +| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | +| `search` | string | no | Search issues against their `title` and `description` | ```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/issues @@ -131,21 +133,23 @@ GET /groups/:id/issues?iids[]=42&iids[]=43 GET /groups/:id/issues?search=issue+title+or+description GET /groups/:id/issues?author_id=5 GET /groups/:id/issues?assignee_id=5 +GET /groups/:id/issues?my_reaction_emoji=star ``` -| Attribute | Type | Required | Description | -|-------------|----------------|----------|-----------------------------------------------------------------------------------------------------------------------------| -| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | -| `state` | string | no | Return all issues or just those that are `opened` or `closed` | -| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels | -| `iids[]` | Array[integer] | no | Return only the issues having the given `iid` | -| `milestone` | string | no | The milestone title | -| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13004] in GitLab 9.5)_ | -| `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | -| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | -| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | -| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | -| `search` | string | no | Search group issues against their `title` and `description` | +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `state` | string | no | Return all issues or just those that are `opened` or `closed` | +| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels | +| `iids[]` | Array[integer] | no | Return only the issues having the given `iid` | +| `milestone` | string | no | The milestone title | +| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13004] in GitLab 9.5)_ | +| `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | +| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | +| `my_reaction_emoji` | string | no | Return issues reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ | +| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | +| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | +| `search` | string | no | Search group issues against their `title` and `description` | ```bash @@ -234,23 +238,25 @@ GET /projects/:id/issues?iids[]=42&iids[]=43 GET /projects/:id/issues?search=issue+title+or+description GET /projects/:id/issues?author_id=5 GET /projects/:id/issues?assignee_id=5 -``` - -| Attribute | Type | Required | Description | -|-------------|----------------|----------|-----------------------------------------------------------------------------------------------------------------------------| -| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `iids[]` | Array[integer] | no | Return only the milestone having the given `iid` | -| `state` | string | no | Return all issues or just those that are `opened` or `closed` | -| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels | -| `milestone` | string | no | The milestone title | -| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13004] in GitLab 9.5)_ | -| `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | -| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | -| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | -| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | -| `search` | string | no | Search project issues against their `title` and `description` | -| `created_after` | datetime | no | Return issues created after the given time (inclusive) | -| `created_before` | datetime | no | Return issues created before the given time (inclusive) | +GET /projects/:id/issues?my_reaction_emoji=star +``` + +| Attribute | Type | Required | Description | +| ------------------- | ---------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `iids[]` | Array[integer] | no | Return only the milestone having the given `iid` | +| `state` | string | no | Return all issues or just those that are `opened` or `closed` | +| `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned. `No+Label` lists all issues with no labels | +| `milestone` | string | no | The milestone title | +| `scope` | string | no | Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13004] in GitLab 9.5)_ | +| `author_id` | integer | no | Return issues created by the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | +| `assignee_id` | integer | no | Return issues assigned to the given user `id` _([Introduced][ce-13004] in GitLab 9.5)_ | +| `my_reaction_emoji` | string | no | Return issues reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ | +| `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | +| `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | +| `search` | string | no | Search project issues against their `title` and `description` | +| `created_after` | datetime | no | Return issues created after the given time (inclusive) | +| `created_before` | datetime | no | Return issues created before the given time (inclusive) | ```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues @@ -594,7 +600,7 @@ POST /projects/:id/issues/:issue_iid/move | `to_project_id` | integer | yes | The ID of the new project | ```bash -curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues/85/move +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data '{"to_project_id": 5}' https://gitlab.example.com/api/v4/projects/4/issues/85/move ``` Example response: @@ -1093,3 +1099,4 @@ Example response: ``` [ce-13004]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13004 +[ce-14016]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14016 diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 4f67aa4b9d4..bff8a2d3e4d 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -22,24 +22,26 @@ GET /merge_requests?state=all GET /merge_requests?milestone=release GET /merge_requests?labels=bug,reproduced GET /merge_requests?author_id=5 +GET /merge_requests?my_reaction_emoji=star GET /merge_requests?scope=assigned-to-me ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged`| -| `order_by`| string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` | -| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` | -| `milestone` | string | no | Return merge requests for a specific milestone | -| `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request | -| `labels` | string | no | Return merge requests matching a comma separated list of labels | -| `created_after` | datetime | no | Return merge requests created after the given time (inclusive) | -| `created_before` | datetime | no | Return merge requests created before the given time (inclusive) | -| `scope` | string | no | Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all`. Defaults to `created-by-me` | -| `author_id` | integer | no | Returns merge requests created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me` | -| `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` | +| Attribute | Type | Required | Description | +| ------------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------- | +| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged` | +| `order_by` | string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` | +| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` | +| `milestone` | string | no | Return merge requests for a specific milestone | +| `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request | +| `labels` | string | no | Return merge requests matching a comma separated list of labels | +| `created_after` | datetime | no | Return merge requests created after the given time (inclusive) | +| `created_before` | datetime | no | Return merge requests created before the given time (inclusive) | +| `scope` | string | no | Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all`. Defaults to `created-by-me` | +| `author_id` | integer | no | Returns merge requests created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me` | +| `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` | +| `my_reaction_emoji` | string | no | Return merge requests reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ | ```json [ @@ -116,25 +118,27 @@ GET /projects/:id/merge_requests?state=all GET /projects/:id/merge_requests?iids[]=42&iids[]=43 GET /projects/:id/merge_requests?milestone=release GET /projects/:id/merge_requests?labels=bug,reproduced +GET /projects/:id/merge_requests?my_reaction_emoji=star ``` Parameters: -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `iids[]` | Array[integer] | no | Return the request having the given `iid` | -| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged`| -| `order_by`| string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` | -| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` | -| `milestone` | string | no | Return merge requests for a specific milestone | -| `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request | -| `labels` | string | no | Return merge requests matching a comma separated list of labels | -| `created_after` | datetime | no | Return merge requests created after the given time (inclusive) | -| `created_before` | datetime | no | Return merge requests created before the given time (inclusive) | -| `scope` | string | no | Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13060] in GitLab 9.5)_ | -| `author_id` | integer | no | Returns merge requests created by the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ | -| `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ | +| Attribute | Type | Required | Description | +| ------------------- | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `id` | integer | yes | The ID of a project | +| `iids[]` | Array[integer] | no | Return the request having the given `iid` | +| `state` | string | no | Return all merge requests or just those that are `opened`, `closed`, or `merged` | +| `order_by` | string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` | +| `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` | +| `milestone` | string | no | Return merge requests for a specific milestone | +| `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request | +| `labels` | string | no | Return merge requests matching a comma separated list of labels | +| `created_after` | datetime | no | Return merge requests created after the given time (inclusive) | +| `created_before` | datetime | no | Return merge requests created before the given time (inclusive) | +| `scope` | string | no | Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13060] in GitLab 9.5)_ | +| `author_id` | integer | no | Returns merge requests created by the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ | +| `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ | +| `my_reaction_emoji` | string | no | Return merge requests reacted by the authenticated user by the given `emoji` _([Introduced][ce-14016] in GitLab 10.0)_ | ```json [ @@ -1315,3 +1319,4 @@ Example response: ``` [ce-13060]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13060 +[ce-14016]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14016 diff --git a/doc/api/pipeline_schedules.md b/doc/api/pipeline_schedules.md index 433654c18cc..c28f48e5fc6 100644 --- a/doc/api/pipeline_schedules.md +++ b/doc/api/pipeline_schedules.md @@ -84,7 +84,13 @@ curl --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/ "state": "active", "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", "web_url": "https://gitlab.example.com/root" - } + }, + "variables": [ + { + "key": "TEST_VARIABLE_1", + "value": "TEST_1" + } + ] } ``` @@ -271,3 +277,86 @@ curl --request DELETE --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gi } } ``` + +## Pipeline schedule variable + +> [Introduced][ce-34518] in GitLab 10.0. + +## Create a new pipeline schedule variable + +Create a new variable of a pipeline schedule. + +``` +POST /projects/:id/pipeline_schedules/:pipeline_schedule_id/variables +``` + +| Attribute | Type | required | Description | +|------------------------|----------------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | +| `key` | string | yes | The `key` of a variable; must have no more than 255 characters; only `A-Z`, `a-z`, `0-9`, and `_` are allowed | +| `value` | string | yes | The `value` of a variable | + +```sh +curl --request POST --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form "key=NEW_VARIABLE" --form "value=new value" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13/variables" +``` + +```json +{ + "key": "NEW_VARIABLE", + "value": "new value" +} +``` + +## Edit a pipeline schedule variable + +Updates the variable of a pipeline schedule. + +``` +PUT /projects/:id/pipeline_schedules/:pipeline_schedule_id/variables/:key +``` + +| Attribute | Type | required | Description | +|------------------------|----------------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | +| `key` | string | yes | The `key` of a variable | +| `value` | string | yes | The `value` of a variable | + +```sh +curl --request PUT --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form "value=updated value" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13/variables/NEW_VARIABLE" +``` + +```json +{ + "key": "NEW_VARIABLE", + "value": "updated value" +} +``` + +## Delete a pipeline schedule variable + +Delete the variable of a pipeline schedule. + +``` +DELETE /projects/:id/pipeline_schedules/:pipeline_schedule_id/variables/:key +``` + +| Attribute | Type | required | Description | +|------------------------|----------------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | +| `key` | string | yes | The `key` of a variable | + +```sh +curl --request DELETE --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13/variables/NEW_VARIABLE" +``` + +```json +{ + "key": "NEW_VARIABLE", + "value": "updated value" +} +``` + +[ce-34518]: https://gitlab.com/gitlab-org/gitlab-ce/issues/34518
\ No newline at end of file diff --git a/doc/api/runners.md b/doc/api/runners.md index 16d362a3530..6304a496f94 100644 --- a/doc/api/runners.md +++ b/doc/api/runners.md @@ -138,7 +138,8 @@ Example response: "ruby", "mysql" ], - "version": null + "version": null, + "access_level": "ref_protected" } ``` @@ -156,6 +157,9 @@ PUT /runners/:id | `description` | string | no | The description of a runner | | `active` | boolean | no | The state of a runner; can be set to `true` or `false` | | `tag_list` | array | no | The list of tags for a runner; put array of tags, that should be finally assigned to a runner | +| `run_untagged` | boolean | no | Flag indicating the runner can execute untagged jobs | +| `locked` | boolean | no | Flag indicating the runner is locked | +| `access_level` | string | no | The access_level of the runner; `not_protected` or `ref_protected` | ``` curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/runners/6" --form "description=test-1-20150125-test" --form "tag_list=ruby,mysql,tag1,tag2" @@ -190,7 +194,8 @@ Example response: "tag1", "tag2" ], - "version": null + "version": null, + "access_level": "ref_protected" } ``` diff --git a/doc/api/settings.md b/doc/api/settings.md index 94a9f8265fb..b78f1252108 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -48,7 +48,11 @@ Example response: "plantuml_enabled": false, "plantuml_url": null, "terminal_max_session_time": 0, - "polling_interval_multiplier": 1.0 + "polling_interval_multiplier": 1.0, + "rsa_key_restriction": 0, + "dsa_key_restriction": 0, + "ecdsa_key_restriction": 0, + "ed25519_key_restriction": 0, } ``` @@ -88,6 +92,10 @@ PUT /application/settings | `plantuml_url` | string | yes (if `plantuml_enabled` is `true`) | The PlantUML instance URL for integration. | | `terminal_max_session_time` | integer | no | Maximum time for web terminal websocket connection (in seconds). Set to 0 for unlimited time. | | `polling_interval_multiplier` | decimal | no | Interval multiplier used by endpoints that perform polling. Set to 0 to disable polling. | +| `rsa_key_restriction` | integer | no | The minimum allowed bit length of an uploaded RSA key. Default is `0` (no restriction). `-1` disables RSA keys. +| `dsa_key_restriction` | integer | no | The minimum allowed bit length of an uploaded DSA key. Default is `0` (no restriction). `-1` disables DSA keys. +| `ecdsa_key_restriction` | integer | no | The minimum allowed curve size (in bits) of an uploaded ECDSA key. Default is `0` (no restriction). `-1` disables ECDSA keys. +| `ed25519_key_restriction` | integer | no | The minimum allowed curve size (in bits) of an uploaded ED25519 key. Default is `0` (no restriction). `-1` disables ED25519 keys. ```bash curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/application/settings?signup_enabled=false&default_project_visibility=internal @@ -125,6 +133,10 @@ Example response: "plantuml_enabled": false, "plantuml_url": null, "terminal_max_session_time": 0, - "polling_interval_multiplier": 1.0 + "polling_interval_multiplier": 1.0, + "rsa_key_restriction": 0, + "dsa_key_restriction": 0, + "ecdsa_key_restriction": 0, + "ed25519_key_restriction": 0, } ``` diff --git a/doc/api/users.md b/doc/api/users.md index 57a13eb477d..57b4e117cf3 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -550,6 +550,217 @@ Parameters: Will return `200 OK` on success, or `404 Not found` if either user or key cannot be found. +## List all GPG keys + +Get a list of currently authenticated user's GPG keys. + +``` +GET /user/gpg_keys +``` + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/user/gpg_keys +``` + +Example response: + +```json +[ + { + "id": 1, + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFVjnlIBCACibzXOLCiZiL2oyzYUaTOCkYnSUhymg3pdbfKtd4mpBa58xKBj\r\nt1pTHVpw3Sk03wmzhM/Ndlt1AV2YhLv++83WKr+gAHFYFiCV/tnY8bx3HqvVoy8O\r\nCfxWhw4QZK7+oYzVmJj8ZJm3ZjOC4pzuegNWlNLCUdZDx9OKlHVXLCX1iUbjdYWa\r\nqKV6tdV8hZolkbyjedQgrpvoWyeSHHpwHF7yk4gNJWMMI5rpcssL7i6mMXb/sDzO\r\nVaAtU5wiVducsOa01InRFf7QSTxoAm6Xy0PGv/k48M6xCALa9nY+BzlOv47jUT57\r\nvilf4Szy9dKD0v9S0mQ+IHB+gNukWrnwtXx5ABEBAAHNFm5hbWUgKGNvbW1lbnQp\r\nIDxlbUBpbD7CwHUEEwECACkFAlVjnlIJEINgJNgv009/AhsDAhkBBgsJCAcDAgYV\r\nCAIJCgsEFgIDAQAAxqMIAFBHuBA8P1v8DtHonIK8Lx2qU23t8Mh68HBIkSjk2H7/\r\noO2cDWCw50jZ9D91PXOOyMPvBWV2IE3tARzCvnNGtzEFRtpIEtZ0cuctxeIF1id5\r\ncrfzdMDsmZyRHAOoZ9VtuD6mzj0ybQWMACb7eIHjZDCee3Slh3TVrLy06YRdq2I4\r\nbjMOPePtK5xnIpHGpAXkB3IONxyITpSLKsA4hCeP7gVvm7r7TuQg1ygiUBlWbBYn\r\niE5ROzqZjG1s7dQNZK/riiU2umGqGuwAb2IPvNiyuGR3cIgRE4llXH/rLuUlspAp\r\no4nlxaz65VucmNbN1aMbDXLJVSqR1DuE00vEsL1AItI=\r\n=XQoy\r\n-----END PGP PUBLIC KEY BLOCK-----", + "created_at": "2017-09-05T09:17:46.264Z" + } +] +``` + +## Get a specific GPG key + +Get a specific GPG key of currently authenticated user. + +``` +GET /user/gpg_keys/:key_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `key_id` | integer | yes | The ID of the GPG key | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/user/gpg_keys/1 +``` + +Example response: + +```json + { + "id": 1, + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFVjnlIBCACibzXOLCiZiL2oyzYUaTOCkYnSUhymg3pdbfKtd4mpBa58xKBj\r\nt1pTHVpw3Sk03wmzhM/Ndlt1AV2YhLv++83WKr+gAHFYFiCV/tnY8bx3HqvVoy8O\r\nCfxWhw4QZK7+oYzVmJj8ZJm3ZjOC4pzuegNWlNLCUdZDx9OKlHVXLCX1iUbjdYWa\r\nqKV6tdV8hZolkbyjedQgrpvoWyeSHHpwHF7yk4gNJWMMI5rpcssL7i6mMXb/sDzO\r\nVaAtU5wiVducsOa01InRFf7QSTxoAm6Xy0PGv/k48M6xCALa9nY+BzlOv47jUT57\r\nvilf4Szy9dKD0v9S0mQ+IHB+gNukWrnwtXx5ABEBAAHNFm5hbWUgKGNvbW1lbnQp\r\nIDxlbUBpbD7CwHUEEwECACkFAlVjnlIJEINgJNgv009/AhsDAhkBBgsJCAcDAgYV\r\nCAIJCgsEFgIDAQAAxqMIAFBHuBA8P1v8DtHonIK8Lx2qU23t8Mh68HBIkSjk2H7/\r\noO2cDWCw50jZ9D91PXOOyMPvBWV2IE3tARzCvnNGtzEFRtpIEtZ0cuctxeIF1id5\r\ncrfzdMDsmZyRHAOoZ9VtuD6mzj0ybQWMACb7eIHjZDCee3Slh3TVrLy06YRdq2I4\r\nbjMOPePtK5xnIpHGpAXkB3IONxyITpSLKsA4hCeP7gVvm7r7TuQg1ygiUBlWbBYn\r\niE5ROzqZjG1s7dQNZK/riiU2umGqGuwAb2IPvNiyuGR3cIgRE4llXH/rLuUlspAp\r\no4nlxaz65VucmNbN1aMbDXLJVSqR1DuE00vEsL1AItI=\r\n=XQoy\r\n-----END PGP PUBLIC KEY BLOCK-----", + "created_at": "2017-09-05T09:17:46.264Z" + } +``` + +## Add a GPG key + +Creates a new GPG key owned by the currently authenticated user. + +``` +POST /user/gpg_keys +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| key | string | yes | The new GPG key | + +```bash +curl --data "key=-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFV..." --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/user/gpg_keys +``` + +Example response: + +```json +[ + { + "id": 1, + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFVjnlIBCACibzXOLCiZiL2oyzYUaTOCkYnSUhymg3pdbfKtd4mpBa58xKBj\r\nt1pTHVpw3Sk03wmzhM/Ndlt1AV2YhLv++83WKr+gAHFYFiCV/tnY8bx3HqvVoy8O\r\nCfxWhw4QZK7+oYzVmJj8ZJm3ZjOC4pzuegNWlNLCUdZDx9OKlHVXLCX1iUbjdYWa\r\nqKV6tdV8hZolkbyjedQgrpvoWyeSHHpwHF7yk4gNJWMMI5rpcssL7i6mMXb/sDzO\r\nVaAtU5wiVducsOa01InRFf7QSTxoAm6Xy0PGv/k48M6xCALa9nY+BzlOv47jUT57\r\nvilf4Szy9dKD0v9S0mQ+IHB+gNukWrnwtXx5ABEBAAHNFm5hbWUgKGNvbW1lbnQp\r\nIDxlbUBpbD7CwHUEEwECACkFAlVjnlIJEINgJNgv009/AhsDAhkBBgsJCAcDAgYV\r\nCAIJCgsEFgIDAQAAxqMIAFBHuBA8P1v8DtHonIK8Lx2qU23t8Mh68HBIkSjk2H7/\r\noO2cDWCw50jZ9D91PXOOyMPvBWV2IE3tARzCvnNGtzEFRtpIEtZ0cuctxeIF1id5\r\ncrfzdMDsmZyRHAOoZ9VtuD6mzj0ybQWMACb7eIHjZDCee3Slh3TVrLy06YRdq2I4\r\nbjMOPePtK5xnIpHGpAXkB3IONxyITpSLKsA4hCeP7gVvm7r7TuQg1ygiUBlWbBYn\r\niE5ROzqZjG1s7dQNZK/riiU2umGqGuwAb2IPvNiyuGR3cIgRE4llXH/rLuUlspAp\r\no4nlxaz65VucmNbN1aMbDXLJVSqR1DuE00vEsL1AItI=\r\n=XQoy\r\n-----END PGP PUBLIC KEY BLOCK-----", + "created_at": "2017-09-05T09:17:46.264Z" + } +] +``` + +## Delete a GPG key + +Delete a GPG key owned by currently authenticated user. + +``` +DELETE /user/gpg_keys/:key_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `key_id` | integer | yes | The ID of the GPG key | + +```bash +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/user/gpg_keys/1 +``` + +Returns `204 No Content` on success, or `404 Not found` if the key cannot be found. + +## List all GPG keys for given user + +Get a list of a specified user's GPG keys. Available only for admins. + +``` +GET /users/:id/gpg_keys +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of the user | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/2/gpg_keys +``` + +Example response: + +```json +[ + { + "id": 1, + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFVjnlIBCACibzXOLCiZiL2oyzYUaTOCkYnSUhymg3pdbfKtd4mpBa58xKBj\r\nt1pTHVpw3Sk03wmzhM/Ndlt1AV2YhLv++83WKr+gAHFYFiCV/tnY8bx3HqvVoy8O\r\nCfxWhw4QZK7+oYzVmJj8ZJm3ZjOC4pzuegNWlNLCUdZDx9OKlHVXLCX1iUbjdYWa\r\nqKV6tdV8hZolkbyjedQgrpvoWyeSHHpwHF7yk4gNJWMMI5rpcssL7i6mMXb/sDzO\r\nVaAtU5wiVducsOa01InRFf7QSTxoAm6Xy0PGv/k48M6xCALa9nY+BzlOv47jUT57\r\nvilf4Szy9dKD0v9S0mQ+IHB+gNukWrnwtXx5ABEBAAHNFm5hbWUgKGNvbW1lbnQp\r\nIDxlbUBpbD7CwHUEEwECACkFAlVjnlIJEINgJNgv009/AhsDAhkBBgsJCAcDAgYV\r\nCAIJCgsEFgIDAQAAxqMIAFBHuBA8P1v8DtHonIK8Lx2qU23t8Mh68HBIkSjk2H7/\r\noO2cDWCw50jZ9D91PXOOyMPvBWV2IE3tARzCvnNGtzEFRtpIEtZ0cuctxeIF1id5\r\ncrfzdMDsmZyRHAOoZ9VtuD6mzj0ybQWMACb7eIHjZDCee3Slh3TVrLy06YRdq2I4\r\nbjMOPePtK5xnIpHGpAXkB3IONxyITpSLKsA4hCeP7gVvm7r7TuQg1ygiUBlWbBYn\r\niE5ROzqZjG1s7dQNZK/riiU2umGqGuwAb2IPvNiyuGR3cIgRE4llXH/rLuUlspAp\r\no4nlxaz65VucmNbN1aMbDXLJVSqR1DuE00vEsL1AItI=\r\n=XQoy\r\n-----END PGP PUBLIC KEY BLOCK-----", + "created_at": "2017-09-05T09:17:46.264Z" + } +] +``` + +## Get a specific GPG key for a given user + +Get a specific GPG key for a given user. Available only for admins. + +``` +GET /users/:id/gpg_keys/:key_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of the user | +| `key_id` | integer | yes | The ID of the GPG key | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/2/gpg_keys/1 +``` + +Example response: + +```json + { + "id": 1, + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFVjnlIBCACibzXOLCiZiL2oyzYUaTOCkYnSUhymg3pdbfKtd4mpBa58xKBj\r\nt1pTHVpw3Sk03wmzhM/Ndlt1AV2YhLv++83WKr+gAHFYFiCV/tnY8bx3HqvVoy8O\r\nCfxWhw4QZK7+oYzVmJj8ZJm3ZjOC4pzuegNWlNLCUdZDx9OKlHVXLCX1iUbjdYWa\r\nqKV6tdV8hZolkbyjedQgrpvoWyeSHHpwHF7yk4gNJWMMI5rpcssL7i6mMXb/sDzO\r\nVaAtU5wiVducsOa01InRFf7QSTxoAm6Xy0PGv/k48M6xCALa9nY+BzlOv47jUT57\r\nvilf4Szy9dKD0v9S0mQ+IHB+gNukWrnwtXx5ABEBAAHNFm5hbWUgKGNvbW1lbnQp\r\nIDxlbUBpbD7CwHUEEwECACkFAlVjnlIJEINgJNgv009/AhsDAhkBBgsJCAcDAgYV\r\nCAIJCgsEFgIDAQAAxqMIAFBHuBA8P1v8DtHonIK8Lx2qU23t8Mh68HBIkSjk2H7/\r\noO2cDWCw50jZ9D91PXOOyMPvBWV2IE3tARzCvnNGtzEFRtpIEtZ0cuctxeIF1id5\r\ncrfzdMDsmZyRHAOoZ9VtuD6mzj0ybQWMACb7eIHjZDCee3Slh3TVrLy06YRdq2I4\r\nbjMOPePtK5xnIpHGpAXkB3IONxyITpSLKsA4hCeP7gVvm7r7TuQg1ygiUBlWbBYn\r\niE5ROzqZjG1s7dQNZK/riiU2umGqGuwAb2IPvNiyuGR3cIgRE4llXH/rLuUlspAp\r\no4nlxaz65VucmNbN1aMbDXLJVSqR1DuE00vEsL1AItI=\r\n=XQoy\r\n-----END PGP PUBLIC KEY BLOCK-----", + "created_at": "2017-09-05T09:17:46.264Z" + } +``` + +## Add a GPG key for a given user + +Create new GPG key owned by the specified user. Available only for admins. + +``` +POST /users/:id/gpg_keys +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of the user | +| `key_id` | integer | yes | The ID of the GPG key | + +```bash +curl --data "key=-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFV..." --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/2/gpg_keys +``` + +Example response: + +```json +[ + { + "id": 1, + "key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFVjnlIBCACibzXOLCiZiL2oyzYUaTOCkYnSUhymg3pdbfKtd4mpBa58xKBj\r\nt1pTHVpw3Sk03wmzhM/Ndlt1AV2YhLv++83WKr+gAHFYFiCV/tnY8bx3HqvVoy8O\r\nCfxWhw4QZK7+oYzVmJj8ZJm3ZjOC4pzuegNWlNLCUdZDx9OKlHVXLCX1iUbjdYWa\r\nqKV6tdV8hZolkbyjedQgrpvoWyeSHHpwHF7yk4gNJWMMI5rpcssL7i6mMXb/sDzO\r\nVaAtU5wiVducsOa01InRFf7QSTxoAm6Xy0PGv/k48M6xCALa9nY+BzlOv47jUT57\r\nvilf4Szy9dKD0v9S0mQ+IHB+gNukWrnwtXx5ABEBAAHNFm5hbWUgKGNvbW1lbnQp\r\nIDxlbUBpbD7CwHUEEwECACkFAlVjnlIJEINgJNgv009/AhsDAhkBBgsJCAcDAgYV\r\nCAIJCgsEFgIDAQAAxqMIAFBHuBA8P1v8DtHonIK8Lx2qU23t8Mh68HBIkSjk2H7/\r\noO2cDWCw50jZ9D91PXOOyMPvBWV2IE3tARzCvnNGtzEFRtpIEtZ0cuctxeIF1id5\r\ncrfzdMDsmZyRHAOoZ9VtuD6mzj0ybQWMACb7eIHjZDCee3Slh3TVrLy06YRdq2I4\r\nbjMOPePtK5xnIpHGpAXkB3IONxyITpSLKsA4hCeP7gVvm7r7TuQg1ygiUBlWbBYn\r\niE5ROzqZjG1s7dQNZK/riiU2umGqGuwAb2IPvNiyuGR3cIgRE4llXH/rLuUlspAp\r\no4nlxaz65VucmNbN1aMbDXLJVSqR1DuE00vEsL1AItI=\r\n=XQoy\r\n-----END PGP PUBLIC KEY BLOCK-----", + "created_at": "2017-09-05T09:17:46.264Z" + } +] +``` + +## Delete a GPG key for a given user + +Delete a GPG key owned by a specified user. Available only for admins. + +``` +DELETE /users/:id/gpg_keys/:key_id +``` + +Parameters: + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer | yes | The ID of the user | +| `key_id` | integer | yes | The ID of the GPG key | + +```bash +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/2/gpg_keys/1 +``` + ## List emails Get a list of currently authenticated user's emails. diff --git a/doc/ci/environments.md b/doc/ci/environments.md index 28b27921f8b..cbf06afa294 100644 --- a/doc/ci/environments.md +++ b/doc/ci/environments.md @@ -274,9 +274,7 @@ session - and even a multiplexer like `screen` or `tmux`! >**Note:** Container-based deployments often lack basic tools (like an editor), and may be stopped or restarted at any time. If this happens, you will lose all your -changes! Treat this as a debugging tool, not a comprehensive online IDE. You -can use [Koding](../administration/integration/koding.md) for online -development. +changes! Treat this as a debugging tool, not a comprehensive online IDE. --- diff --git a/doc/ci/examples/README.md b/doc/ci/examples/README.md index 2458cb959ab..f094546c3bd 100644 --- a/doc/ci/examples/README.md +++ b/doc/ci/examples/README.md @@ -50,12 +50,15 @@ Apart from those, here is an collection of tutorials and guides on setting up yo - **Articles:** - [Setting up GitLab CI for Android projects](https://about.gitlab.com/2016/11/30/setting-up-gitlab-ci-for-android-projects/) +### Code quality analysis + +- [Analyze code quality with the Code Climate CLI](code_climate.md) + ### Other - [Using `dpl` as deployment tool](deployment/README.md) - [Repositories with examples for various languages](https://gitlab.com/groups/gitlab-examples) - [The .gitlab-ci.yml file for GitLab itself](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab-ci.yml) -- [Analyze code quality with the Code Climate CLI](code_climate.md) - **Articles:** - [Continuous Deployment with GitLab: how to build and deploy a Debian Package with GitLab CI](https://about.gitlab.com/2016/10/12/automated-debian-package-build-with-gitlab-ci/) diff --git a/doc/ci/examples/code_climate.md b/doc/ci/examples/code_climate.md index 5659a8c2a2a..4d0ba8bfef3 100644 --- a/doc/ci/examples/code_climate.md +++ b/doc/ci/examples/code_climate.md @@ -5,10 +5,10 @@ GitLab CI and Docker. First, you need GitLab Runner with [docker-in-docker executor][dind]. -Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `codeclimate`: +Once you set up the Runner, add a new job to `.gitlab-ci.yml`, called `codequality`: ```yaml -codeclimate: +codequality: image: docker:latest variables: DOCKER_DRIVER: overlay @@ -22,7 +22,7 @@ codeclimate: paths: [codeclimate.json] ``` -This will create a `codeclimate` job in your CI pipeline and will allow you to +This will create a `codequality` job in your CI pipeline and will allow you to download and analyze the report artifact in JSON format. For GitLab [Enterprise Edition Starter][ee] users, this information can be automatically diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md index 76d746155eb..f5d3b524d6e 100644 --- a/doc/ci/runners/README.md +++ b/doc/ci/runners/README.md @@ -107,6 +107,43 @@ To lock/unlock a Runner: 1. Check the **Lock to current projects** option 1. Click **Save changes** for the changes to take effect +## Assigning a Runner to another project + +If you are Master on a project where a specific Runner is assigned to, and the +Runner is not [locked only to that project](#locking-a-specific-runner-from-being-enabled-for-other-projects), +you can enable the Runner also on any other project where you have Master permissions. + +To enable/disable a Runner in your project: + +1. Visit your project's **Settings ➔ Pipelines** +1. Find the Runner you wish to enable/disable +1. Click **Enable for this project** or **Disable for this project** + +> **Note**: +Consider that if you don't lock your specific Runner to a specific project, any +user with Master role in you project can assign your runner to another arbitrary +project without requiring your authorization, so use it with caution. + +## Protected Runners + +> +[Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13194) +in GitLab 10.0. + +You can protect Runners from revealing sensitive information. +Whenever a Runner is protected, the Runner picks only jobs created on +[protected branches] or [protected tags], and ignores other jobs. + +To protect/unprotect Runners: + +1. Visit your project's **Settings ➔ Pipelines** +1. Find a Runner you want to protect/unprotect and make sure it's enabled +1. Click the pencil button besides the Runner name +1. Check the **Protected** option +1. Click **Save changes** for the changes to take effect + +![specific Runners edit icon](img/protected_runners_check_box.png) + ## How shared Runners pick jobs Shared Runners abide to a process queue we call fair usage. The fair usage @@ -218,3 +255,5 @@ We're always looking for contributions that can mitigate these [install]: http://docs.gitlab.com/runner/install/ [fifo]: https://en.wikipedia.org/wiki/FIFO_(computing_and_electronics) [register]: http://docs.gitlab.com/runner/register/ +[protected branches]: ../../user/project/protected_branches.md +[protected tags]: ../../user/project/protected_tags.md diff --git a/doc/ci/runners/img/protected_runners_check_box.png b/doc/ci/runners/img/protected_runners_check_box.png Binary files differnew file mode 100644 index 00000000000..fb58498c7ce --- /dev/null +++ b/doc/ci/runners/img/protected_runners_check_box.png diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index abf4ec7dbf8..d0ac3ec6163 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -130,7 +130,7 @@ There are also two edge cases worth mentioning: ### types -> Deprecated, and will be removed in 10.0. Use [stages](#stages) instead. +> Deprecated, and could be removed in one of the future releases. Use [stages](#stages) instead. Alias for [stages](#stages). @@ -427,16 +427,16 @@ a "key: value" pair. Be careful when using special characters: are executed in `parallel`. For more info about the use of `stage` please check [stages](#stages). -### only and except +### only and except (simplified) -`only` and `except` are two parameters that set a refs policy to limit when -jobs are built: +`only` and `except` are two parameters that set a job policy to limit when +jobs are created: 1. `only` defines the names of branches and tags for which the job will run. 2. `except` defines the names of branches and tags for which the job will **not** run. -There are a few rules that apply to the usage of refs policy: +There are a few rules that apply to the usage of job policy: * `only` and `except` are inclusive. If both `only` and `except` are defined in a job specification, the ref is filtered by `only` and `except`. @@ -497,6 +497,36 @@ job: The above example will run `job` for all branches on `gitlab-org/gitlab-ce`, except master. +### only and except (complex) + +> Introduced in GitLab 10.0 + +> This an _alpha_ feature, and it it subject to change at any time without + prior notice! + +Since GitLab 10.0 it is possible to define a more elaborate only/except job +policy configuration. + +GitLab now supports both, simple and complex strategies, so it is possible to +use an array and a hash configuration scheme. + +Two keys are now available: `refs` and `kubernetes`. Refs strategy equals to +simplified only/except configuration, whereas kubernetes strategy accepts only +`active` keyword. + +See the example below. Job is going to be created only when pipeline has been +scheduled or runs for a `master` branch, and only if kubernetes service is +active in the project. + +```yaml +job: + only: + refs: + - master + - schedules + kubernetes: active +``` + ### Job variables It is possible to define job variables using a `variables` keyword on a job diff --git a/doc/development/fe_guide/vue.md b/doc/development/fe_guide/vue.md index 0742b202807..2607353782a 100644 --- a/doc/development/fe_guide/vue.md +++ b/doc/development/fe_guide/vue.md @@ -28,8 +28,9 @@ As always, the Frontend Architectural Experts are available to help with any Vue All new features built with Vue.js must follow a [Flux architecture][flux]. The main goal we are trying to achieve is to have only one data flow and only one data entry. -In order to achieve this goal, each Vue bundle needs a Store - where we keep all the data -, -a Service - that we use to communicate with the server - and a main Vue component. +In order to achieve this goal, you can either use [vuex](#vuex) or use the [store pattern][state-management], explained below: + +Each Vue bundle needs a Store - where we keep all the data -,a Service - that we use to communicate with the server - and a main Vue component. Think of the Main Vue Component as the entry point of your application. This is the only smart component that should exist in each Vue feature. @@ -74,6 +75,59 @@ provided as a prop to the main component. Don't forget to follow [these steps.][page_specific_javascript] +### Bootstrapping Gotchas +#### Providing data from Haml to JavaScript +While mounting a Vue application may be a need to provide data from Rails to JavaScript. +To do that, provide the data through `data` attributes in the HTML element and query them while mounting the application. + +_Note:_ You should only do this while initing the application, because the mounted element will be replaced with Vue-generated DOM. + +The advantage of providing data from the DOM to the Vue instance through `props` in the `render` function +instead of querying the DOM inside the main vue component is that makes tests easier by avoiding the need to +create a fixture or an HTML element in the unit test. See the following example: + +```javascript +// haml +.js-vue-app{ data: { endpoint: 'foo' }} + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '.js-vue-app', + data() { + const dataset = this.$options.el.dataset; + return { + endpoint: dataset.endpoint, + }; + }, + render(createElement) { + return createElement('my-component', { + props: { + endpoint: this.isLoading, + }, + }); + }, +})); +``` + +#### Accessing the `gl` object +When we need to query the `gl` object for data that won't change during the application's lyfecyle, we should do it in the same place where we query the DOM. +By following this practice, we can avoid the need to mock the `gl` object, which will make tests easier. +It should be done while initializing our Vue instance, and the data should be provided as `props` to the main component: + +##### example: +```javascript + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '.js-vue-app', + render(createElement) { + return createElement('my-component', { + props: { + username: gon.current_username, + }, + }); + }, +})); +``` + ### A folder for Components This folder holds all components that are specific of this new feature. @@ -89,6 +143,29 @@ in one table would not be a good use of this pattern. You can read more about components in Vue.js site, [Component System][component-system] +#### Components Gotchas +1. Using SVGs in components: To use an SVG in a template we need to make it a property we can access through the component. +A `prop` and a property returned by the `data` functions require `vue` to set a `getter` and a `setter` for each of them. +The SVG should be a computed property in order to improve performance, note that computed properties are cached based on their dependencies. + +```javascript +// bad +import svg from 'svg.svg'; +data() { + return { + myIcon: svg, + }; +}; + +// good +import svg from 'svg.svg'; +computed: { + myIcon() { + return svg; + } +} +``` + ### A folder for the Store The Store is a class that allows us to manage the state in a single @@ -430,11 +507,23 @@ describe('Todos App', () => { }); }); ``` +#### `mountComponent` helper +There is an helper in `spec/javascripts/helpers/vue_mount_component_helper.js` that allows you to mount a component with the given props: + +```javascript +import Vue from 'vue'; +import mountComponent from 'helpers/vue_mount_component_helper.js' +import component from 'component.vue' + +const Component = Vue.extend(component); +const data = {prop: 'foo'}; +const vm = mountComponent(Component, data); +``` + #### Test the component's output The main return value of a Vue component is the rendered output. In order to test the component we need to test the rendered output. [Vue][vue-test] guide's to unit test show us exactly that: - ### Stubbing API responses [Vue Resource Interceptors][vue-resource-interceptor] allow us to add a interceptor with the response we need: @@ -481,6 +570,198 @@ new Component({ new Component().$mount(); ``` +## Vuex +To manage the state of an application you may use [Vuex][vuex-docs]. + +_Note:_ All of the below is explained in more detail in the official [Vuex documentation][vuex-docs]. + +### Separation of concerns +Vuex is composed of State, Getters, Mutations, Actions and Modules. + +When a user clicks on an action, we need to `dispatch` it. This action will `commit` a mutation that will change the state. +_Note:_ The action itself will not update the state, only a mutation should update the state. + +#### File structure +When using Vuex at GitLab, separate this concerns into different files to improve readability. If you can, separate the Mutation Types as well: + +``` +└── store + ├── index.js # where we assemble modules and export the store + ├── actions.js # actions + ├── mutations.js # mutations + ├── getters.js # getters + └── mutation_types.js # mutation types +``` +The following examples show an application that lists and adds users to the state. + +##### `index.js` +This is the entry point for our store. You can use the following as a guide: + +```javascript +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + actions, + getters, + state: { + users: [], + }, +}); +``` +_Note:_ If the state of the application is too complex, an individual file for the state may be better. + +#### `actions.js` +An action commits a mutatation. In this file, we will write the actions that will call the respective mutation: + +```javascript + import * as types from './mutation-types' + + export const addUser = ({ commit }, user) => { + commit(types.ADD_USER, user); + }; +``` + +To dispatch an action from a component, use the `mapActions` helper: +```javascript +import { mapActions } from 'vuex'; + +{ + methods: { + ...mapActions([ + 'addUser', + ]), + onClickUser(user) { + this.addUser(user); + }, + }, +}; +``` + +#### `getters.js` +Sometimes we may need to get derived state based on store state, like filtering for a specific prop. This can be done through the `getters`: + +```javascript +// get all the users with pets +export getUsersWithPets = (state, getters) => { + return state.users.filter(user => user.pet !== undefined); +}; +``` + +To access a getter from a component, use the `mapGetters` helper: +```javascript +import { mapGetters } from 'vuex'; + +{ + computed: { + ...mapGetters([ + 'getUsersWithPets', + ]), + }, +}; +``` + +#### `mutations.js` +The only way to actually change state in a Vuex store is by committing a mutation. + +```javascript + import * as types from './mutation-types' + export default { + [types.ADD_USER](state, user) { + state.users.push(user); + }, + }; +``` + +#### `mutations_types.js` +From [vuex mutations docs][vuex-mutations]: +> It is a commonly seen pattern to use constants for mutation types in various Flux implementations. This allows the code to take advantage of tooling like linters, and putting all constants in a single file allows your collaborators to get an at-a-glance view of what mutations are possible in the entire application. + +```javascript +export const ADD_USER = 'ADD_USER'; +``` + +### How to include the store in your application +The store should be included in the main component of your application: +```javascript + // app.vue + import store from 'store'; // it will include the index.js file + + export default { + name: 'application', + store, + ... + }; +``` + +### Vuex Gotchas +1. Avoid calling a mutation directly. Always use an action to commit a mutation. Doing so will keep consistency through out the application. From Vuex docs: + + > why don't we just call store.commit('action') directly? Well, remember that mutations must be synchronous? Actions aren't. We can perform asynchronous operations inside an action. + + ```javascript + // component.vue + + // bad + created() { + this.$store.commit('mutation'); + } + + // good + created() { + this.$store.dispatch('action'); + } + ``` +1. When possible, use mutation types instead of hardcoding strings. It will be less error prone. +1. The State will be accessible in all components descending from the use where the store is instantiated. + +### Testing Vuex +#### Testing Vuex concerns +Refer to [vuex docs][vuex-testing] regarding testing Actions, Getters and Mutations. + +#### Testing components that need a store +Smaller components might use `store` properties to access the data. +In order to write unit tests for those components, we need to include the store and provide the correct state: + +```javascript +//component_spec.js +import Vue from 'vue'; +import store from './store'; +import component from './component.vue' + +describe('component', () => { + let vm; + let Component; + + beforeEach(() => { + Component = Vue.extend(issueActions); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should show a user', () => { + const user = { + name: 'Foo', + age: '30', + }; + + // populate the store + store.dipatch('addUser', user); + + vm = new Component({ + store, + propsData: props, + }).$mount(); + }); +}); +``` + [vue-docs]: http://vuejs.org/guide/index.html [issue-boards]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/app/assets/javascripts/boards [environments-table]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/app/assets/javascripts/environments @@ -493,3 +774,7 @@ new Component().$mount(); [vue-test]: https://vuejs.org/v2/guide/unit-testing.html [issue-boards-service]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/assets/javascripts/boards/services/board_service.js.es6 [flux]: https://facebook.github.io/flux +[vuex-docs]: https://vuex.vuejs.org +[vuex-structure]: https://vuex.vuejs.org/en/structure.html +[vuex-mutations]: https://vuex.vuejs.org/en/mutations.html +[vuex-testing]: https://vuex.vuejs.org/en/testing.html diff --git a/doc/development/i18n_guide.md b/doc/development/i18n_guide.md index 756535e28bc..bd0ef39ca62 100644 --- a/doc/development/i18n_guide.md +++ b/doc/development/i18n_guide.md @@ -138,6 +138,47 @@ translations. There's no need to generate `.po` files. Translations that aren't used in the source code anymore will be marked with `~#`; these can be removed to keep our translation files clutter-free. +### Validating PO files + +To make sure we keep our translation files up to date, there's a linter that is +running on CI as part of the `static-analysis` job. + +To lint the adjustments in PO files locally you can run `rake gettext:lint`. + +The linter will take the following into account: + +- Valid PO-file syntax +- Variable usage + - Only one unnamed (`%d`) variable, since the order of variables might change + in different languages + - All variables used in the message-id are used in the translation + - There should be no variables used in a translation that aren't in the + message-id +- Errors during translation. + +The errors are grouped per file, and per message ID: + +``` +Errors in `locale/zh_HK/gitlab.po`: + PO-syntax errors + SimplePoParser::ParserErrorSyntax error in lines + Syntax error in msgctxt + Syntax error in msgid + Syntax error in msgstr + Syntax error in message_line + There should be only whitespace until the end of line after the double quote character of a message text. + Parseing result before error: '{:msgid=>["", "You are going to remove %{project_name_with_namespace}.\\n", "Removed project CANNOT be restored!\\n", "Are you ABSOLUTELY sure?"]}' + SimplePoParser filtered backtrace: SimplePoParser::ParserError +Errors in `locale/zh_TW/gitlab.po`: + 1 pipeline + <%d 條流水線> is using unknown variables: [%d] + Failure translating to zh_TW with []: too few arguments +``` + +In this output the `locale/zh_HK/gitlab.po` has syntax errors. +The `locale/zh_TW/gitlab.po` has variables that are used in the translation that +aren't in the message with id `1 pipeline`. + ## Working with special content ### Interpolation diff --git a/doc/development/licensing.md b/doc/development/licensing.md index 2b16dfe0e7c..9a5811d8474 100644 --- a/doc/development/licensing.md +++ b/doc/development/licensing.md @@ -55,6 +55,7 @@ Libraries with the following licenses are acceptable for use: - [BSD 3-Clause License][BSD-3-Clause] (also known as New BSD or Modified BSD): A permissive (non-copyleft) license as defined by the Open Source Initiative - [ISC License][ISC] (also known as the OpenBSD License): A permissive (non-copyleft) license as defined by the Open Source Initiative. - [Creative Commons Zero (CC0)][CC0]: A public domain dedication, recommended as a way to disclaim copyright on your work to the maximum extent possible. +- [Unlicense][UNLICENSE]: Another public domain dedication. ## Unacceptable Licenses @@ -63,6 +64,7 @@ Libraries with the following licenses are unacceptable for use: - [GNU GPL][GPL] (version 1, [version 2][GPLv2], [version 3][GPLv3], or any future versions): GPL-licensed libraries cannot be linked to from non-GPL projects. - [GNU AGPLv3][AGPLv3]: AGPL-licensed libraries cannot be linked to from non-GPL projects. - [Open Software License (OSL)][OSL]: is a copyleft license. In addition, the FSF [recommend against its use][OSL-GNU]. +- [Facebook BSD + PATENTS][Facebook]: is a 3-clause BSD license with a patent grant that has been deemed [Category X][x-list] by the Apache foundation. ## Requesting Approval for Licenses @@ -101,5 +103,8 @@ Gems which are included only in the "development" or "test" groups by Bundler ar [OSL]: https://opensource.org/licenses/OSL-3.0 [OSL-GNU]: https://www.gnu.org/licenses/license-list.en.html#OSL [Org-Repo]: https://gitlab.com/gitlab-com/organization +[UNLICENSE]: https://unlicense.org +[Facebook]: https://code.facebook.com/pages/850928938376556 +[x-list]: https://www.apache.org/legal/resolved.html#category-x [Acceptable-Licenses]: #acceptable-licenses [Unacceptable-Licenses]: #unacceptable-licenses diff --git a/doc/install/kubernetes/gitlab_chart.md b/doc/install/kubernetes/gitlab_chart.md index 6bcc58bb805..a339bc23809 100644 --- a/doc/install/kubernetes/gitlab_chart.md +++ b/doc/install/kubernetes/gitlab_chart.md @@ -1,9 +1,9 @@ # GitLab Helm Chart > **Note:** -* GitLab is working on a [cloud native set of Charts](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md) which will replace these. +* > **Note**: This chart will be replaced by the [gitlab-omnibus](gitlab_omnibus.md) chart, once it supports [additional configuration options](https://gitlab.com/charts/charts.gitlab.io/issues/68). * Officially supported cloud providers are Google Container Service and Azure Container Service. -The `gitlab` Helm chart deploys GitLab into your Kubernetes cluster. +The `gitlab` Helm chart deploys just GitLab into your Kubernetes cluster, and offers extensive configuration options. For most deployments we recommended the [gitlab-omnibus](gitlab_omnibus.md) chart, This chart includes the following: diff --git a/doc/install/kubernetes/gitlab_omnibus.md b/doc/install/kubernetes/gitlab_omnibus.md index 8636ce2507c..d7fd8613633 100644 --- a/doc/install/kubernetes/gitlab_omnibus.md +++ b/doc/install/kubernetes/gitlab_omnibus.md @@ -21,13 +21,6 @@ The deployment includes: A video demonstration of GitLab utilizing this chart [is available](https://about.gitlab.com/handbook/sales/demo/). -Terms: - -- Google Cloud Platform (**GCP**) -- Google Container Engine (**GKE**) -- Azure Container Service (**ACS**) -- Kubernetes (**k8s**) - ## Prerequisites - _At least_ 4 GB of RAM available on your cluster. 41GB of storage and 2 CPU are also required. @@ -64,14 +57,14 @@ For production deployments of GitLab, we strongly recommend using an [External I ## Configuring and Installing GitLab For most installations, only two parameters are required: -- `baseDomain`: the [base domain](#networking-prerequisites) with the wildcard host entry resolving to the `baseIP`. For example, `mycompany.io`. -- `legoEmail`: Email address to use when requesting new SSL certificates from Let's Encrypt +- `baseDomain`: the [base domain](#networking-prerequisites) of the wildcard host entry. For example, `mycompany.io` if the wild card entry is `*.mycompany.io`. +- `legoEmail`: Email address to use when requesting new SSL certificates from Let's Encrypt. Other common configuration options: -- `baseIP`: the desired [external IP address](#networking-prerequisites) +- `baseIP`: the desired [external IP address](#external-ip-recommended) - `gitlab`: Choose the [desired edition](https://about.gitlab.com/products), either `ee` or `ce`. `ce` is the default. - `gitlabEELicense`: For Enterprise Edition, the [license](https://docs.gitlab.com/ee/user/admin_area/license.html) can be installed directly via the Chart -- `provider`: Optimizes the deployment for a cloud provider. The default is `gke` for GCP, with `acs` also supported for Azure. +- `provider`: Optimizes the deployment for a cloud provider. The default is `gke` for [Google Container Engine](https://cloud.google.com/container-engine/), with `acs` also supported for the [Azure Container Service](https://azure.microsoft.com/en-us/services/container-service/). For additional configuration options, consult the [values.yaml](https://gitlab.com/charts/charts.gitlab.io/blob/master/charts/gitlab-omnibus/values.yaml). @@ -82,20 +75,16 @@ the value of the corresponding helm setting: `gitlabCEImage` or `gitabEEImage`. ```yaml gitlab: CE -gitlabCEImage: gitlab/gitlab-ce:9.1.2-ce.0 -gitlabEEImage: gitlab/gitlab-ee:9.1.2-ee.0 +gitlabCEImage: gitlab/gitlab-ce:9.5.2-ce.0 +gitlabEEImage: gitlab/gitlab-ee:9.5.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. - ### Persistent storage > **Note:** -If you are using a machine type with support for less than 4 attached disks, like an Azure trial, you should disable dedicated storage for [Postgres and Redis](#persistent-storage). +If you are using a machine type with support for less than 4 attached disks, like an Azure trial, you should disable dedicated storage for Postgres and Redis. By default, persistent storage is enabled for GitLab and the charts it depends on (Redis and PostgreSQL). diff --git a/doc/install/kubernetes/index.md b/doc/install/kubernetes/index.md index 8418b04936b..fb6c0c2d263 100644 --- a/doc/install/kubernetes/index.md +++ b/doc/install/kubernetes/index.md @@ -7,31 +7,37 @@ 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. -GitLab provides [official Helm Charts](#official-gitlab-helm-charts-recommended) which is the recommended way to run GitLab with Kubernetes. +GitLab provides [official Helm Charts](#official-gitlab-helm-charts-recommended) which are the recommended way to run GitLab within Kubernetes. There are also two other sets of charts: * Our [upcoming cloud native Charts](#upcoming-cloud-native-helm-charts), which are in development but will eventually replace the current official charts. * [Community contributed charts](#community-contributed-helm-charts). These charts should be considered deprecated, in favor of the official charts. -## Official GitLab Helm Charts (Recommended) +## Official GitLab Helm Charts These charts utilize our [GitLab Omnibus Docker images](https://docs.gitlab.com/omnibus/docker/README.html). You can report any issues and feedback related to these charts at https://gitlab.com/charts/charts.gitlab.io/issues. -### Deploying GitLab on Kubernetes (Recommended) -> *Note*: This chart will eventually be replaced by the [cloud native charts](#upcoming-cloud-native-helm-charts), which are presently in development. +### Deploying GitLab on Kubernetes +> **Note**: This chart will eventually be replaced by the [cloud native charts](#upcoming-cloud-native-helm-charts), which are presently in development. -The best way to deploy GitLab on Kubernetes is to use the [gitlab-omnibus](gitlab_omnibus.md) chart. It includes everything needed to run GitLab, including: a [Runner](https://docs.gitlab.com/runner/), [Container Registry](https://docs.gitlab.com/ee/user/project/container_registry.html#gitlab-container-registry), [automatic SSL](https://github.com/kubernetes/charts/tree/master/stable/kube-lego), and an [Ingress](https://github.com/kubernetes/ingress/tree/master/controllers/nginx). This chart is in beta while [additional features](https://gitlab.com/charts/charts.gitlab.io/issues/68) are being completed. +The best way to deploy GitLab on Kubernetes is to use the [gitlab-omnibus](gitlab_omnibus.md) chart. + +It includes everything needed to run GitLab, including: a [Runner](https://docs.gitlab.com/runner/), [Container Registry](https://docs.gitlab.com/ee/user/project/container_registry.html#gitlab-container-registry), [automatic SSL](https://github.com/kubernetes/charts/tree/master/stable/kube-lego), and an [Ingress](https://github.com/kubernetes/ingress/tree/master/controllers/nginx). This chart is in beta while [additional features](https://gitlab.com/charts/charts.gitlab.io/issues/68) are being completed. ### Deploying just the GitLab Runner -To deploy just the GitLab Runner, utilize the [gitlab-runner](gitlab_runner_chart.md) chart. It offers a quick way to configure and deploy the Runner on Kubernetes, regardless of where your GitLab server may be running. +To deploy just the [GitLab Runner](https://docs.gitlab.com/runner/), utilize the [gitlab-runner](gitlab_runner_chart.md) chart. + +It offers a quick way to configure and deploy the Runner on Kubernetes, regardless of where your GitLab server may be running. -### Advanced deployment of GitLab (Not recommended) -> *Note*: This chart will eventually be replaced by the [cloud native charts](#upcoming-cloud-native-helm-charts), which are presently in development. +### Advanced deployment of GitLab +> **Note**: This chart will be replaced by the [gitlab-omnibus](gitlab_omnibus.md) chart, once it supports [additional configuration options](https://gitlab.com/charts/charts.gitlab.io/issues/68). If advanced configuration of GitLab is required, the beta [gitlab](gitlab_chart.md) chart can be used which deploys the GitLab service along with optional Postgres and Redis. It offers extensive configuration, but requires deep knowledge of Kubernetes and Helm to use. +For most deployments we recommend using our [gitlab-omnibus](gitlab_omnibus.md) chart. + ## Upcoming Cloud Native Helm Charts GitLab is working towards a building a [cloud native deployment method](https://gitlab.com/charts/helm.gitlab.io/blob/master/README.md). A key part of this effort is to isolate each service into it's [own Docker container and Helm chart](https://gitlab.com/gitlab-org/omnibus-gitlab/issues/2420), rather than utilizing the all-in-one container image of the [current charts](#official-gitlab-helm-charts-recommended). diff --git a/doc/integration/README.md b/doc/integration/README.md index d70b9a7f54b..09d96bdd338 100644 --- a/doc/integration/README.md +++ b/doc/integration/README.md @@ -13,7 +13,6 @@ Bitbucket.org account - [External issue tracker](external-issue-tracker.md) Redmine, JIRA, etc. - [Gmail actions buttons](gmail_action_buttons_for_gitlab.md) Adds GitLab actions to messages - [JIRA](../user/project/integrations/jira.md) Integrate with the JIRA issue tracker -- [Koding](../administration/integration/koding.md) Configure Koding to use IDE integration - [LDAP](ldap.md) Set up sign in via LDAP - [OAuth2 provider](oauth_provider.md) OAuth2 application creation - [OmniAuth](omniauth.md) Sign in via Twitter, GitHub, GitLab.com, Google, Bitbucket, Facebook, Shibboleth, SAML, Crowd, Azure and Authentiq ID diff --git a/doc/security/README.md b/doc/security/README.md index 38706e48ec5..0fea6be8b55 100644 --- a/doc/security/README.md +++ b/doc/security/README.md @@ -1,6 +1,7 @@ # Security - [Password length limits](password_length_limits.md) +- [Restrict SSH key technologies and minimum length](ssh_keys_restrictions.md) - [Rack attack](rack_attack.md) - [Webhooks and insecure internal web services](webhooks.md) - [Information exclusivity](information_exclusivity.md) diff --git a/doc/security/img/ssh_keys_restrictions_settings.png b/doc/security/img/ssh_keys_restrictions_settings.png Binary files differnew file mode 100644 index 00000000000..2e918fd4b3f --- /dev/null +++ b/doc/security/img/ssh_keys_restrictions_settings.png diff --git a/doc/security/ssh_keys_restrictions.md b/doc/security/ssh_keys_restrictions.md new file mode 100644 index 00000000000..213fa5bfef5 --- /dev/null +++ b/doc/security/ssh_keys_restrictions.md @@ -0,0 +1,19 @@ +# Restrict allowed SSH key technologies and minimum length + +`ssh-keygen` allows users to create RSA keys with as few as 768 bits, which +falls well below recommendations from certain standards groups (such as the US +NIST). Some organizations deploying GitLab will need to enforce minimum key +strength, either to satisfy internal security policy or for regulatory +compliance. + +Similarly, certain standards groups recommend using RSA, ECDSA, or ED25519 over +the older DSA, and administrators may need to limit the allowed SSH key +algorithms. + +GitLab allows you to restrict the allowed SSH key technology as well as specify +the minimum key length for each technology. + +In the Admin area under **Settings** (`/admin/application_settings`), look for +the "Visibility and Access Controls" area: + +![SSH keys restriction admin settings](img/ssh_keys_restrictions_settings.png) diff --git a/doc/ssh/README.md b/doc/ssh/README.md index cf28f1a2eca..793de9d777c 100644 --- a/doc/ssh/README.md +++ b/doc/ssh/README.md @@ -193,6 +193,38 @@ How to add your SSH key to Eclipse: https://wiki.eclipse.org/EGit/User_Guide#Ecl [winputty]: https://the.earth.li/~sgtatham/putty/0.67/htmldoc/Chapter8.html#pubkey-puttygen +## SSH on the GitLab server + +GitLab integrates with the system-installed SSH daemon, designating a user +(typically named `git`) through which all access requests are handled. Users +connecting to the GitLab server over SSH are identified by their SSH key instead +of their username. + +SSH *client* operations performed on the GitLab server wil be executed as this +user. Although it is possible to modify the SSH configuration for this user to, +e.g., provide a private SSH key to authenticate these requests by, this practice +is **not supported** and is strongly discouraged as it presents significant +security risks. + +The GitLab check process includes a check for this condition, and will direct you +to this section if your server is configured like this, e.g.: + +``` +$ gitlab-rake gitlab:check +# ... +Git user has default SSH configuration? ... no + Try fixing it: + mkdir ~/gitlab-check-backup-1504540051 + sudo mv /var/lib/git/.ssh/id_rsa ~/gitlab-check-backup-1504540051 + sudo mv /var/lib/git/.ssh/id_rsa.pub ~/gitlab-check-backup-1504540051 + For more information see: + doc/ssh/README.md in section "SSH on the GitLab server" + Please fix the error above and rerun the checks. +``` + +Remove the custom configuration as soon as you're able to. These customizations +are *explicitly not supported* and may stop working at any time. + ## Troubleshooting If on Git clone you are prompted for a password like `git@gitlab.com's password:` diff --git a/doc/user/permissions.md b/doc/user/permissions.md index dcf210e1085..bd0a58c4cca 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -21,16 +21,16 @@ The following table depicts the various user permission levels in a project. | Action | Guest | Reporter | Developer | Master | Owner | |---------------------------------------|---------|------------|-------------|----------|--------| -| Create new issue | ✓ | ✓ | ✓ | ✓ | ✓ | -| Create confidential issue | ✓ | ✓ | ✓ | ✓ | ✓ | -| View confidential issues | (✓) [^1] | ✓ | ✓ | ✓ | ✓ | -| Leave comments | ✓ | ✓ | ✓ | ✓ | ✓ | -| See a list of jobs | ✓ [^2] | ✓ | ✓ | ✓ | ✓ | -| See a job log | ✓ [^2] | ✓ | ✓ | ✓ | ✓ | -| Download and browse job artifacts | ✓ [^2] | ✓ | ✓ | ✓ | ✓ | -| View wiki pages | ✓ | ✓ | ✓ | ✓ | ✓ | -| Pull project code | | ✓ | ✓ | ✓ | ✓ | -| Download project | | ✓ | ✓ | ✓ | ✓ | +| Create new issue | ✓ [^1] | ✓ | ✓ | ✓ | ✓ | +| Create confidential issue | ✓ [^1] | ✓ | ✓ | ✓ | ✓ | +| View confidential issues | (✓) [^2] | ✓ | ✓ | ✓ | ✓ | +| Leave comments | ✓ [^1] | ✓ | ✓ | ✓ | ✓ | +| See a list of jobs | ✓ [^3] | ✓ | ✓ | ✓ | ✓ | +| See a job log | ✓ [^3] | ✓ | ✓ | ✓ | ✓ | +| Download and browse job artifacts | ✓ [^3] | ✓ | ✓ | ✓ | ✓ | +| View wiki pages | ✓ [^1] | ✓ | ✓ | ✓ | ✓ | +| Pull project code | [^1] | ✓ | ✓ | ✓ | ✓ | +| Download project | [^1] | ✓ | ✓ | ✓ | ✓ | | Create code snippets | | ✓ | ✓ | ✓ | ✓ | | Manage issue tracker | | ✓ | ✓ | ✓ | ✓ | | Manage labels | | ✓ | ✓ | ✓ | ✓ | @@ -71,8 +71,8 @@ The following table depicts the various user permission levels in a project. | Switch visibility level | | | | | ✓ | | Transfer project to another namespace | | | | | ✓ | | Remove project | | | | | ✓ | -| Force push to protected branches [^3] | | | | | | -| Remove protected branches [^3] | | | | | | +| Force push to protected branches [^4] | | | | | | +| Remove protected branches [^4] | | | | | | | Remove pages | | | | | ✓ | ## Project features permissions @@ -215,13 +215,13 @@ users: | Run CI job | | ✓ | ✓ | ✓ | | Clone source and LFS from current project | | ✓ | ✓ | ✓ | | Clone source and LFS from public projects | | ✓ | ✓ | ✓ | -| Clone source and LFS from internal projects | | ✓ [^4] | ✓ [^4] | ✓ | -| Clone source and LFS from private projects | | ✓ [^5] | ✓ [^5] | ✓ [^5] | +| Clone source and LFS from internal projects | | ✓ [^5] | ✓ [^5] | ✓ | +| Clone source and LFS from private projects | | ✓ [^6] | ✓ [^6] | ✓ [^6] | | Push source and LFS | | | | | | Pull container images from current project | | ✓ | ✓ | ✓ | | Pull container images from public projects | | ✓ | ✓ | ✓ | -| Pull container images from internal projects| | ✓ [^4] | ✓ [^4] | ✓ | -| Pull container images from private projects | | ✓ [^5] | ✓ [^5] | ✓ [^5] | +| Pull container images from internal projects| | ✓ [^5] | ✓ [^5] | ✓ | +| Pull container images from private projects | | ✓ [^6] | ✓ [^6] | ✓ [^6] | | Push container images to current project | | ✓ | ✓ | ✓ | | Push container images to other projects | | | | | @@ -243,12 +243,11 @@ with the permissions described on the documentation on [auditor users permission Auditor users are available in [GitLab Enterprise Edition Premium](https://about.gitlab.com/gitlab-ee/) only. ----- - -[^1]: Guest users can only view the confidential issues they created themselves -[^2]: If **Public pipelines** is enabled in **Project Settings > Pipelines** -[^3]: Not allowed for Guest, Reporter, Developer, Master, or Owner -[^4]: Only if user is not external one. -[^5]: Only if user is a member of the project. +[^1]: On public and internal projects, all users are able to perform this action. +[^2]: Guest users can only view the confidential issues they created themselves +[^3]: If **Public pipelines** is enabled in **Project Settings > Pipelines** +[^4]: Not allowed for Guest, Reporter, Developer, Master, or Owner +[^5]: Only if user is not external one. +[^6]: Only if user is a member of the project. [ce-18994]: https://gitlab.com/gitlab-org/gitlab-ce/issues/18994 [new-mod]: project/new_ci_build_permissions_model.md diff --git a/doc/user/project/import/index.md b/doc/user/project/import/index.md index 2a8728ed96e..67e856a97cd 100644 --- a/doc/user/project/import/index.md +++ b/doc/user/project/import/index.md @@ -7,6 +7,7 @@ 1. [From Gitea](gitea.md) 1. [From SVN](svn.md) 1. [From ClearCase](clearcase.md) +1. [From Perforce](perforce.md) In addition to the specific migration documentation above, you can import any Git repository via HTTP from the New Project page. Be aware that if the diff --git a/doc/user/project/import/perforce.md b/doc/user/project/import/perforce.md new file mode 100644 index 00000000000..aa7508e1e8e --- /dev/null +++ b/doc/user/project/import/perforce.md @@ -0,0 +1,50 @@ +# Migrating from Perforce Helix + +[Perforce Helix](https://www.perforce.com/) provides a set of tools which also +include a centralized, proprietary version control system similar to Git. + +## Perforce vs Git + +The following list illustrates the main differences between Perforce Helix and +Git: + +1. In general the biggest difference is that Perforce branching is heavyweight + compared to Git's lightweight branching. When you create a branch in Perforce, + it creates an integration record in their proprietary database for every file + in the branch, regardless how many were actually changed. Whereas Git was + implemented with a different architecture so that a single SHA acts as a pointer + to the state of the whole repo after the changes, making it very easy to branch. + This is what made feature branching workflows so easy to adopt with Git. +1. Also, context switching between branches is much easier in Git. If your manager + said 'You need to stop work on that new feature and fix this security + vulnerability' you can do so very easily in Git. +1. Having a complete copy of the project and its history on your local machine + means every transaction is superfast and Git provides that. You can branch/merge + and experiment in isolation, then clean up your mess before sharing your new + cool stuff with everyone. +1. Git also made code review simple because you could share your changes without + merging them to master, whereas Perforce had to implement a Shelving feature on + the server so others could review changes before merging. + +## Why migrate + +Perforce Helix can be difficult to manage both from a user and an admin +perspective. Migrating to Git/GitLab there is: + +- **No licensing costs**, Git is GPL while Perforce Helix is proprietary. +- **Shorter learning curve**, Git has a big community and a vast number of + tutorials to get you started. +- **Integration with modern tools**, migrating to Git and GitLab you can have + an open source end-to-end software development platform with built-in version + control, issue tracking, code review, CI/CD, and more. + +## How to migrate + +Git includes a built-in mechanism (`git p4`) to pull code from Perforce and to +submit back from Git to Perforce. + +Here's a few links to get you started: + +- [git-p4 manual page](https://www.kernel.org/pub/software/scm/git/docs/git-p4.html) +- [git-p4 example usage](https://git.wiki.kernel.org/index.php/Git-p4_Usage) +- [Git book migration guide](https://git-scm.com/book/en/v2/Git-and-Other-Systems-Migrating-to-Git#_perforce_import) diff --git a/doc/user/project/index.md b/doc/user/project/index.md index 41a96246292..d6b3d59d407 100644 --- a/doc/user/project/index.md +++ b/doc/user/project/index.md @@ -67,8 +67,6 @@ website with GitLab Pages **Other features:** - [Cycle Analytics](cycle_analytics.md): Review your development lifecycle -- [Koding integration](koding.md) (not available on GitLab.com): Integrate -with Koding to have access to a web terminal right from the GitLab UI - [Syntax highlighting](highlighting.md): An alternative to customize your code blocks, overriding GitLab's default choice of language diff --git a/doc/user/project/issues/img/sidebar_move_issue.png b/doc/user/project/issues/img/sidebar_move_issue.png Binary files differnew file mode 100644 index 00000000000..111f7861364 --- /dev/null +++ b/doc/user/project/issues/img/sidebar_move_issue.png diff --git a/doc/user/project/issues/index.md b/doc/user/project/issues/index.md index 20901e01f6e..0f187946a4a 100644 --- a/doc/user/project/issues/index.md +++ b/doc/user/project/issues/index.md @@ -86,6 +86,10 @@ Read through the [documentation on creating issues](create_new_issue.md). Learn distinct ways to [close issues](closing_issues.md) in GitLab. +## Moving issues + +Read through the [documentation on moving issues](moving_issues.md). + ## Create a merge request from an issue Learn more about it on the [GitLab Issues Functionalities documentation](issues_functionalities.md#18-new-merge-request). diff --git a/doc/user/project/issues/moving_issues.md b/doc/user/project/issues/moving_issues.md new file mode 100644 index 00000000000..211a651b89e --- /dev/null +++ b/doc/user/project/issues/moving_issues.md @@ -0,0 +1,10 @@ +# Moving Issues + +Please read through the [GitLab Issue Documentation](index.md) for an overview on GitLab Issues. + +Moving an issue will close it and duplicate it on the specified project. +There will also be a system note added to both issues indicating where it came from or went to. + +You can move an issue with the "Move issue" button at the bottom of the right-sidebar when viewing the issue. + +![move issue - button](img/sidebar_move_issue.png) diff --git a/doc/user/project/koding.md b/doc/user/project/koding.md index 455e2ee47b4..86e06a39e59 100644 --- a/doc/user/project/koding.md +++ b/doc/user/project/koding.md @@ -1,6 +1,9 @@ # Koding integration -> [Introduced][ce-5909] in GitLab 8.11. +>**Notes:** +- **As of GitLab 10.0, the Koding integration is deprecated and will be removed + in a future version.** +- [Introduced][ce-5909] in GitLab 8.11. This document will guide you through using Koding integration on GitLab in detail. For configuring and installing please follow the diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md index ce4dd4e99d5..6a5d2d40927 100644 --- a/doc/user/project/quick_actions.md +++ b/doc/user/project/quick_actions.md @@ -38,3 +38,4 @@ do. | `/award :emoji:` | Toggle award for :emoji: | | `/board_move ~column` | Move issue to column on the board | | `/duplicate #issue` | Closes this issue and marks it as a duplicate of another issue | +| `/move path/to/project` | Moves issue to another project | diff --git a/doc/user/project/repository/gpg_signed_commits/img/project_signed_and_unsigned_commits.png b/doc/user/project/repository/gpg_signed_commits/img/project_signed_and_unsigned_commits.png Binary files differindex 33936a7d6d7..088ecfa6d89 100644 --- a/doc/user/project/repository/gpg_signed_commits/img/project_signed_and_unsigned_commits.png +++ b/doc/user/project/repository/gpg_signed_commits/img/project_signed_and_unsigned_commits.png diff --git a/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_unverified_signature.png b/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_unverified_signature.png Binary files differindex 22565cf7c7e..4e3392406b1 100644 --- a/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_unverified_signature.png +++ b/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_unverified_signature.png diff --git a/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_verified_signature.png b/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_verified_signature.png Binary files differindex 1778b2ddf2b..766970dee81 100644 --- a/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_verified_signature.png +++ b/doc/user/project/repository/gpg_signed_commits/img/project_signed_commit_verified_signature.png diff --git a/doc/user/project/repository/gpg_signed_commits/index.md b/doc/user/project/repository/gpg_signed_commits/index.md index ff419d714f9..afe8066d408 100644 --- a/doc/user/project/repository/gpg_signed_commits/index.md +++ b/doc/user/project/repository/gpg_signed_commits/index.md @@ -22,11 +22,12 @@ GitLab uses its own keyring to verify the GPG signature. It does not access any public key server. In order to have a commit verified on GitLab the corresponding public key needs -to be uploaded to GitLab. For a signature to be verified two prerequisites need +to be uploaded to GitLab. For a signature to be verified three conditions need to be met: 1. The public key needs to be added your GitLab account 1. One of the emails in the GPG key matches your **primary** email +1. The committer's email matches the verified email from the gpg key ## Generating a GPG key diff --git a/doc/user/search/img/issue_search_by_term.png b/doc/user/search/img/issue_search_by_term.png Binary files differnew file mode 100644 index 00000000000..66e612c4ea6 --- /dev/null +++ b/doc/user/search/img/issue_search_by_term.png diff --git a/doc/user/search/index.md b/doc/user/search/index.md index f5c7ce49e8e..21e96d8b11c 100644 --- a/doc/user/search/index.md +++ b/doc/user/search/index.md @@ -40,6 +40,20 @@ The same process is valid for merge requests. Navigate to your project's **Merge and click **Search or filter results...**. Merge requests can be filtered by author, assignee, milestone, and label. +### Searching for specific terms + +You can filter issues and merge requests by specific terms included in titles or descriptions. + +* Syntax + * Searches look for all the words in a query, in any order. E.g.: searching + issues for `display bug` will return all issues matching both those words, in any order. + * To find the exact term, use double quotes: `"display bug"` +* Limitation + * For performance reasons, terms shorter than 3 chars are ignored. E.g.: searching + issues for `included in titles` is same as `included titles` + +![filter issues by specific terms](img/issue_search_by_term.png) + ### Issues and merge requests per group Similar to **Issues and merge requests per project**, you can also search for issues diff --git a/features/steps/explore/projects.rb b/features/steps/explore/projects.rb index f1288c15084..8fb2ac34c32 100644 --- a/features/steps/explore/projects.rb +++ b/features/steps/explore/projects.rb @@ -36,13 +36,13 @@ class Spinach::Features::ExploreProjects < Spinach::FeatureSteps end step 'I should see project "Community" home page' do - page.within '.navbar-gitlab .title' do + page.within '.breadcrumbs .title' do expect(page).to have_content 'Community' end end step 'I should see project "Internal" home page' do - page.within '.navbar-gitlab .title' do + page.within '.breadcrumbs .title' do expect(page).to have_content 'Internal' end end diff --git a/features/steps/group/milestones.rb b/features/steps/group/milestones.rb index f6559b6be2f..20edcf75ff1 100644 --- a/features/steps/group/milestones.rb +++ b/features/steps/group/milestones.rb @@ -47,7 +47,9 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps end step 'I click new milestone button' do - click_link "New milestone" + page.within('.breadcrumbs') do + click_link "New milestone" + end end step 'I press create mileston button' do diff --git a/features/steps/project/active_tab.rb b/features/steps/project/active_tab.rb index 5cd9bd38c9d..1a18f1d7065 100644 --- a/features/steps/project/active_tab.rb +++ b/features/steps/project/active_tab.rb @@ -22,25 +22,25 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps end step 'I click the "Edit Project"' do - page.within '.sub-nav' do + page.within '.nav-sidebar' do click_link('Edit Project') end end step 'I click the "Integrations" tab' do - page.within '.sub-nav' do + page.within '.nav-sidebar' do click_link('Integrations') end end step 'I click the "Repository" tab' do - page.within '.sub-nav' do + page.within '.sidebar-top-level-items > .active' do click_link('Repository') end end step 'I click the "Activity" tab' do - page.within '.sub-nav' do + page.within '.sidebar-top-level-items > .active' do click_link('Activity') end end @@ -72,7 +72,7 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps end step 'I click the "Branches" tab' do - page.within '.sub-nav' do + page.within '.nav-sidebar' do click_link('Branches') end end @@ -82,7 +82,7 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps end step 'I click the "Charts" tab' do - page.within '.sub-nav' do + page.within('.sidebar-top-level-items > .active') do click_link('Charts') end end @@ -102,13 +102,13 @@ class Spinach::Features::ProjectActiveTab < Spinach::FeatureSteps # Sub Tabs: Issues step 'I click the "Milestones" sub tab' do - page.within('.sub-nav') do + page.within('.nav-sidebar') do click_link('Milestones') end end step 'I click the "Labels" sub tab' do - page.within('.sub-nav') do + page.within('.nav-sidebar') do click_link('Labels') end end diff --git a/features/steps/project/fork.rb b/features/steps/project/fork.rb index dd4dff7f7a9..3b8d9af96c1 100644 --- a/features/steps/project/fork.rb +++ b/features/steps/project/fork.rb @@ -36,7 +36,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps end step 'I goto the Merge Requests page' do - page.within '.layout-nav' do + page.within '.nav-sidebar' do click_link "Merge Requests" end end diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb index 2deef9036d3..f7dd4fc21e9 100644 --- a/features/steps/project/issues/issues.rb +++ b/features/steps/project/issues/issues.rb @@ -62,7 +62,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps end step 'I click link "New issue"' do - page.within '#content-body' do + page.within '.breadcrumbs' do page.has_link?('New Issue') ? click_link('New Issue') : click_link('New issue') end end @@ -168,6 +168,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps author: project.users.first, description: "# Description header" ) + wait_for_requests end step 'project "Shop" have "Tweet control" open issue' do diff --git a/features/steps/project/issues/milestones.rb b/features/steps/project/issues/milestones.rb index d4345a4a432..4765bb7cebe 100644 --- a/features/steps/project/issues/milestones.rb +++ b/features/steps/project/issues/milestones.rb @@ -17,7 +17,9 @@ class Spinach::Features::ProjectIssuesMilestones < Spinach::FeatureSteps end step 'I click link "New Milestone"' do - click_link "New milestone" + page.within('.breadcrumbs') do + click_link "New milestone" + end end step 'I submit new milestone "v2.3"' do diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index 9201639ac83..a57edc287be 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -15,7 +15,7 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps end step 'I click link "New Merge Request"' do - page.within '#content-body' do + page.within '.breadcrumbs' do page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request') end end diff --git a/features/steps/project/pages.rb b/features/steps/project/pages.rb index bb69c0d6e99..124a132d688 100644 --- a/features/steps/project/pages.rb +++ b/features/steps/project/pages.rb @@ -23,13 +23,13 @@ class Spinach::Features::ProjectPages < Spinach::FeatureSteps end step 'I should see the "Pages" tab' do - page.within '.sub-nav' do + page.within '.nav-sidebar' do expect(page).to have_link('Pages') end end step 'I should not see the "Pages" tab' do - page.within '.sub-nav' do + page.within '.nav-sidebar' do expect(page).not_to have_link('Pages') end end @@ -37,7 +37,8 @@ class Spinach::Features::ProjectPages < Spinach::FeatureSteps step 'pages are deployed' do pipeline = @project.pipelines.create(ref: 'HEAD', sha: @project.commit('HEAD').sha, - source: :push) + source: :push, + protected: false) build = build(:ci_build, project: @project, diff --git a/features/steps/project/project_milestone.rb b/features/steps/project/project_milestone.rb index a7d3352b8c4..b2d08515e77 100644 --- a/features/steps/project/project_milestone.rb +++ b/features/steps/project/project_milestone.rb @@ -55,7 +55,7 @@ class Spinach::Features::ProjectMilestone < Spinach::FeatureSteps end step 'I click link "Labels"' do - page.within('.layout-nav .nav-links') do + page.within('.nav-sidebar') do page.find(:xpath, "//a[@href='#tab-labels']").click end end diff --git a/features/steps/project/redirects.rb b/features/steps/project/redirects.rb index 53a2463af53..100e674abed 100644 --- a/features/steps/project/redirects.rb +++ b/features/steps/project/redirects.rb @@ -18,7 +18,7 @@ class Spinach::Features::ProjectRedirects < Spinach::FeatureSteps step 'I should see project "Community" home page' do Gitlab.config.gitlab.should_receive(:host).and_return("www.example.com") - page.within '.navbar-gitlab .title' do + page.within '.breadcrumbs .title' do expect(page).to have_content 'Community' end end diff --git a/features/steps/project/snippets.rb b/features/steps/project/snippets.rb index d9bf373a0d7..0646a70acfd 100644 --- a/features/steps/project/snippets.rb +++ b/features/steps/project/snippets.rb @@ -23,7 +23,7 @@ class Spinach::Features::ProjectSnippets < Spinach::FeatureSteps end step 'I click link "New snippet"' do - page.within '#content-body' do + page.within '.breadcrumbs' do first(:link, "New snippet").click end end diff --git a/features/steps/shared/active_tab.rb b/features/steps/shared/active_tab.rb index af5db05e9e8..2bb21a798aa 100644 --- a/features/steps/shared/active_tab.rb +++ b/features/steps/shared/active_tab.rb @@ -7,11 +7,11 @@ module SharedActiveTab end def ensure_active_main_tab(content) - expect(find('.layout-nav li.active')).to have_content(content) + expect(find('.sidebar-top-level-items > li.active')).to have_content(content) end def ensure_active_sub_tab(content) - expect(find('.sub-nav li.active')).to have_content(content) + expect(find('.sidebar-sub-level-items > li.active')).to have_content(content) end def ensure_active_sub_nav(content) @@ -19,11 +19,11 @@ module SharedActiveTab end step 'no other main tabs should be active' do - expect(page).to have_selector('.layout-nav .nav-links > li.active', count: 1) + expect(page).to have_selector('.sidebar-top-level-items > li.active', count: 1) end step 'no other sub tabs should be active' do - expect(page).to have_selector('.sub-nav li.active', count: 1) + expect(page).to have_selector('.sidebar-sub-level-items > li.active', count: 1) end step 'no other sub navs should be active' do diff --git a/features/steps/shared/group.rb b/features/steps/shared/group.rb index de119f2c6c0..03bc7e798e0 100644 --- a/features/steps/shared/group.rb +++ b/features/steps/shared/group.rb @@ -36,14 +36,12 @@ module SharedGroup protected def is_member_of(username, groupname, role) - @project_count ||= 0 user = User.find_by(name: username) || create(:user, name: username) group = Group.find_by(name: groupname) || create(:group, name: groupname) group.add_user(user, role) - project ||= create(:project, :repository, namespace: group, path: "project#{@project_count}") + project ||= create(:project, :repository, namespace: group) create(:closed_issue_event, project: project) project.team << [user, :master] - @project_count += 1 end def owned_group diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb index 92f442db646..95f0cd2156e 100644 --- a/features/steps/shared/note.rb +++ b/features/steps/shared/note.rb @@ -137,7 +137,7 @@ module SharedNote step 'The comment with the header should not have an ID' do page.within(".note-body > .note-text") do - expect(page).to have_content("Comment with a header") + expect(page).to have_content("Comment with a header") expect(page).not_to have_css("#comment-with-a-header") end end @@ -150,15 +150,20 @@ module SharedNote note.find('.js-note-edit').click end + page.find('.current-note-edit-form textarea') + page.within(".current-note-edit-form") do fill_in 'note[note]', with: '+1 Awesome!' click_button 'Save comment' end + wait_for_requests end step 'I should see +1 in the description' do page.within(".note") do expect(page).to have_content("+1 Awesome!") end + + wait_for_requests end end diff --git a/features/steps/shared/project_tab.rb b/features/steps/shared/project_tab.rb index 901f7f76ee9..5a516ee33bc 100644 --- a/features/steps/shared/project_tab.rb +++ b/features/steps/shared/project_tab.rb @@ -5,7 +5,7 @@ module SharedProjectTab include SharedActiveTab step 'the active main tab should be Project' do - ensure_active_main_tab('Project') + ensure_active_main_tab('Overview') end step 'the active main tab should be Repository' do @@ -53,7 +53,7 @@ module SharedProjectTab end step 'the active sub tab should be Home' do - ensure_active_sub_tab('Home') + ensure_active_sub_tab('Details') end step 'the active sub tab should be Activity' do diff --git a/lib/api/access_requests.rb b/lib/api/access_requests.rb index 4fa9b2b2494..374b611f55e 100644 --- a/lib/api/access_requests.rb +++ b/lib/api/access_requests.rb @@ -10,7 +10,7 @@ module API params do requires :id, type: String, desc: "The #{source_type} ID" end - resource source_type.pluralize, requirements: { id: %r{[^/]+} } do + resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc "Gets a list of access requests for a #{source_type}." do detail 'This feature was introduced in GitLab 8.11.' success Entities::AccessRequester diff --git a/lib/api/award_emoji.rb b/lib/api/award_emoji.rb index 8e3851640da..c3d93996816 100644 --- a/lib/api/award_emoji.rb +++ b/lib/api/award_emoji.rb @@ -12,7 +12,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do AWARDABLES.each do |awardable_params| awardable_string = awardable_params[:type].pluralize awardable_id_string = "#{awardable_params[:type]}_#{awardable_params[:find_by]}" diff --git a/lib/api/boards.rb b/lib/api/boards.rb index 0d11c5fc971..366b0dc9a6f 100644 --- a/lib/api/boards.rb +++ b/lib/api/boards.rb @@ -7,7 +7,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get all project boards' do detail 'This feature was introduced in 8.13' success Entities::Board diff --git a/lib/api/branches.rb b/lib/api/branches.rb index a989394ad91..642c1140fcc 100644 --- a/lib/api/branches.rb +++ b/lib/api/branches.rb @@ -24,17 +24,22 @@ module API present paginate(branches), with: Entities::RepoBranch, project: user_project end - desc 'Get a single branch' do - success Entities::RepoBranch - end - params do - requires :branch, type: String, desc: 'The name of the branch' - end - get ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do - branch = user_project.repository.find_branch(params[:branch]) - not_found!("Branch") unless branch + resource ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do + desc 'Get a single branch' do + success Entities::RepoBranch + end + params do + requires :branch, type: String, desc: 'The name of the branch' + end + head do + user_project.repository.branch_exists?(params[:branch]) ? status(204) : status(404) + end + get do + branch = user_project.repository.find_branch(params[:branch]) + not_found!('Branch') unless branch - present branch, with: Entities::RepoBranch, project: user_project + present branch, with: Entities::RepoBranch, project: user_project + end end # Note: This API will be deprecated in favor of the protected branches API. diff --git a/lib/api/commit_statuses.rb b/lib/api/commit_statuses.rb index 485b680cd5f..829eef18795 100644 --- a/lib/api/commit_statuses.rb +++ b/lib/api/commit_statuses.rb @@ -5,7 +5,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do include PaginationParams before { authenticate! } @@ -74,7 +74,8 @@ module API source: :external, sha: commit.sha, ref: ref, - user: current_user) + user: current_user, + protected: @project.protected_for?(ref)) end status = GenericCommitStatus.running_or_pending.find_or_initialize_by( @@ -82,7 +83,8 @@ module API pipeline: pipeline, name: name, ref: ref, - user: current_user + user: current_user, + protected: @project.protected_for?(ref) ) optional_attributes = @@ -101,7 +103,7 @@ module API when 'success' status.success! when 'failed' - status.drop! + status.drop!(:api_failure) when 'canceled' status.cancel! else diff --git a/lib/api/deploy_keys.rb b/lib/api/deploy_keys.rb index f405c341398..281269b1190 100644 --- a/lib/api/deploy_keys.rb +++ b/lib/api/deploy_keys.rb @@ -17,7 +17,7 @@ module API params do requires :id, type: String, desc: 'The ID of the project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do before { authorize_admin_project } desc "Get a specific project's deploy keys" do diff --git a/lib/api/deployments.rb b/lib/api/deployments.rb index 46b936897f6..1efee9a1324 100644 --- a/lib/api/deployments.rb +++ b/lib/api/deployments.rb @@ -8,7 +8,7 @@ module API params do requires :id, type: String, desc: 'The project ID' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get all deployments of the project' do detail 'This feature was introduced in GitLab 8.11.' success Entities::Deployment diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 803b48dd88a..031dd02c6eb 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -1,11 +1,11 @@ module API module Entities class UserSafe < Grape::Entity - expose :name, :username + expose :id, :name, :username end class UserBasic < UserSafe - expose :id, :state + expose :state expose :avatar_url do |user, options| user.avatar_url(only_path: false) end @@ -491,6 +491,10 @@ module API expose :user, using: Entities::UserPublic end + class GPGKey < Grape::Entity + expose :id, :key, :created_at + end + class Note < Grape::Entity # Only Issue and MergeRequest have iid NOTEABLE_TYPES_WITH_IID = %w(Issue MergeRequest).freeze @@ -775,6 +779,7 @@ module API expose :tag_list expose :run_untagged expose :locked + expose :access_level expose :version, :revision, :platform, :architecture expose :contacted_at expose :token, if: lambda { |runner, options| options[:current_user].admin? || !runner.is_shared? } @@ -818,7 +823,7 @@ module API class Variable < Grape::Entity expose :key, :value - expose :protected?, as: :protected + expose :protected?, as: :protected, if: -> (entity, _) { entity.respond_to?(:protected?) } end class Pipeline < PipelineBasic @@ -839,6 +844,7 @@ module API class PipelineScheduleDetails < PipelineSchedule expose :last_pipeline, using: Entities::PipelineBasic + expose :variables, using: Entities::Variable end class EnvironmentBasic < Grape::Entity diff --git a/lib/api/environments.rb b/lib/api/environments.rb index e33269f9483..5c63ec028d9 100644 --- a/lib/api/environments.rb +++ b/lib/api/environments.rb @@ -9,7 +9,7 @@ module API params do requires :id, type: String, desc: 'The project ID' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get all environments of the project' do detail 'This feature was introduced in GitLab 8.11.' success Entities::Environment diff --git a/lib/api/events.rb b/lib/api/events.rb index dabdf579119..b0713ff1d54 100644 --- a/lib/api/events.rb +++ b/lib/api/events.rb @@ -67,7 +67,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc "List a Project's visible events" do success Entities::Event end diff --git a/lib/api/group_milestones.rb b/lib/api/group_milestones.rb index b85eb59dc0a..93fa0b95857 100644 --- a/lib/api/group_milestones.rb +++ b/lib/api/group_milestones.rb @@ -10,7 +10,7 @@ module API params do requires :id, type: String, desc: 'The ID of a group' end - resource :groups, requirements: { id: %r{[^/]+} } do + resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get a list of group milestones' do success Entities::Milestone end diff --git a/lib/api/group_variables.rb b/lib/api/group_variables.rb index 25152f30998..92800ce6450 100644 --- a/lib/api/group_variables.rb +++ b/lib/api/group_variables.rb @@ -9,7 +9,7 @@ module API requires :id, type: String, desc: 'The ID of a group' end - resource :groups, requirements: { id: %r{[^/]+} } do + resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get group-level variables' do success Entities::Variable end diff --git a/lib/api/groups.rb b/lib/api/groups.rb index 8c494a54329..31a918eda60 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -89,7 +89,7 @@ module API params do requires :id, type: String, desc: 'The ID of a group' end - resource :groups, requirements: { id: %r{[^/]+} } do + resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Update a group. Available only for users who can administrate groups.' do success Entities::Group end diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb index ecb79317093..f57ff0f2632 100644 --- a/lib/api/helpers/internal_helpers.rb +++ b/lib/api/helpers/internal_helpers.rb @@ -42,6 +42,10 @@ module API ::Users::ActivityService.new(actor, 'Git SSH').execute if commands.include?(params[:action]) end + def merge_request_urls + ::MergeRequests::GetUrlsService.new(project).execute(params[:changes]) + end + private def set_project diff --git a/lib/api/internal.rb b/lib/api/internal.rb index 8b007869dc3..622bd9650e4 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -68,7 +68,7 @@ module API end get "/merge_request_urls" do - ::MergeRequests::GetUrlsService.new(project).execute(params[:changes]) + merge_request_urls end # @@ -155,6 +155,21 @@ module API # render_api_error!(e, 500) # end end + + post '/post_receive' do + status 200 + + PostReceive.perform_async(params[:gl_repository], params[:identifier], + params[:changes]) + broadcast_message = BroadcastMessage.current&.last&.message + reference_counter_decreased = Gitlab::ReferenceCounter.new(params[:gl_repository]).decrease + + { + merge_request_urls: merge_request_urls, + broadcast_message: broadcast_message, + reference_counter_decreased: reference_counter_decreased + } + end end end end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 0297023226f..1729df2aad0 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -36,6 +36,7 @@ module API optional :assignee_id, type: Integer, desc: 'Return issues which are assigned to the user with the given ID' optional :scope, type: String, values: %w[created-by-me assigned-to-me all], desc: 'Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all`' + optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji' use :pagination end @@ -81,7 +82,7 @@ module API params do requires :id, type: String, desc: 'The ID of a group' end - resource :groups, requirements: { id: %r{[^/]+} } do + resource :groups, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get a list of group issues' do success Entities::IssueBasic end @@ -108,7 +109,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do include TimeTrackingEndpoints desc 'Get a list of project issues' do diff --git a/lib/api/jobs.rb b/lib/api/jobs.rb index a40018b214e..5bab96398fd 100644 --- a/lib/api/jobs.rb +++ b/lib/api/jobs.rb @@ -7,7 +7,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do helpers do params :optional_scope do optional :scope, types: [String, Array[String]], desc: 'The scope of builds to show', diff --git a/lib/api/labels.rb b/lib/api/labels.rb index c0cf618ee8d..e41a1720ac1 100644 --- a/lib/api/labels.rb +++ b/lib/api/labels.rb @@ -7,7 +7,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get all labels of the project' do success Entities::Label end diff --git a/lib/api/members.rb b/lib/api/members.rb index a5d3d7f25a0..22e4bdead41 100644 --- a/lib/api/members.rb +++ b/lib/api/members.rb @@ -10,7 +10,7 @@ module API params do requires :id, type: String, desc: "The #{source_type} ID" end - resource source_type.pluralize, requirements: { id: %r{[^/]+} } do + resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Gets a list of group or project members viewable by the authenticated user.' do success Entities::Member end diff --git a/lib/api/merge_request_diffs.rb b/lib/api/merge_request_diffs.rb index 4b79eac2b8b..c3affcc6c6b 100644 --- a/lib/api/merge_request_diffs.rb +++ b/lib/api/merge_request_diffs.rb @@ -8,7 +8,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get a list of merge request diff versions' do detail 'This feature was introduced in GitLab 8.12.' success Entities::MergeRequestDiff diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index eec8d9357aa..56d72d511da 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -40,6 +40,7 @@ module API optional :assignee_id, type: Integer, desc: 'Return merge requests which are assigned to the user with the given ID' optional :scope, type: String, values: %w[created-by-me assigned-to-me all], desc: 'Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all`' + optional :my_reaction_emoji, type: String, desc: 'Return issues reacted by the authenticated user by the given emoji' use :pagination end end @@ -72,7 +73,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do include TimeTrackingEndpoints helpers do diff --git a/lib/api/notes.rb b/lib/api/notes.rb index e116448c15b..d6e7203adaf 100644 --- a/lib/api/notes.rb +++ b/lib/api/notes.rb @@ -9,7 +9,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do NOTEABLE_TYPES.each do |noteable_type| noteables_str = noteable_type.to_s.underscore.pluralize diff --git a/lib/api/notification_settings.rb b/lib/api/notification_settings.rb index 5d113c94b22..bcc0833aa5c 100644 --- a/lib/api/notification_settings.rb +++ b/lib/api/notification_settings.rb @@ -54,7 +54,7 @@ module API params do requires :id, type: String, desc: "The #{source_type} ID" end - resource source_type.pluralize, requirements: { id: %r{[^/]+} } do + resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc "Get #{source_type} level notification level settings, defaults to Global" do detail 'This feature was introduced in GitLab 8.12' success Entities::NotificationSetting diff --git a/lib/api/pipeline_schedules.rb b/lib/api/pipeline_schedules.rb index e3123ef4e2d..37f32411296 100644 --- a/lib/api/pipeline_schedules.rb +++ b/lib/api/pipeline_schedules.rb @@ -7,7 +7,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get all pipeline schedules' do success Entities::PipelineSchedule end @@ -31,10 +31,6 @@ module API requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' end get ':id/pipeline_schedules/:pipeline_schedule_id' do - authorize! :read_pipeline_schedule, user_project - - not_found!('PipelineSchedule') unless pipeline_schedule - present pipeline_schedule, with: Entities::PipelineScheduleDetails end @@ -74,9 +70,6 @@ module API optional :active, type: Boolean, desc: 'The activation of pipeline schedule' end put ':id/pipeline_schedules/:pipeline_schedule_id' do - authorize! :read_pipeline_schedule, user_project - - not_found!('PipelineSchedule') unless pipeline_schedule authorize! :update_pipeline_schedule, pipeline_schedule if pipeline_schedule.update(declared_params(include_missing: false)) @@ -93,9 +86,6 @@ module API requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' end post ':id/pipeline_schedules/:pipeline_schedule_id/take_ownership' do - authorize! :read_pipeline_schedule, user_project - - not_found!('PipelineSchedule') unless pipeline_schedule authorize! :update_pipeline_schedule, pipeline_schedule if pipeline_schedule.own!(current_user) @@ -112,21 +102,84 @@ module API requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' end delete ':id/pipeline_schedules/:pipeline_schedule_id' do - authorize! :read_pipeline_schedule, user_project - - not_found!('PipelineSchedule') unless pipeline_schedule authorize! :admin_pipeline_schedule, pipeline_schedule destroy_conditionally!(pipeline_schedule) end + + desc 'Create a new pipeline schedule variable' do + success Entities::Variable + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + requires :key, type: String, desc: 'The key of the variable' + requires :value, type: String, desc: 'The value of the variable' + end + post ':id/pipeline_schedules/:pipeline_schedule_id/variables' do + authorize! :update_pipeline_schedule, pipeline_schedule + + variable_params = declared_params(include_missing: false) + variable = pipeline_schedule.variables.create(variable_params) + if variable.persisted? + present variable, with: Entities::Variable + else + render_validation_error!(variable) + end + end + + desc 'Edit a pipeline schedule variable' do + success Entities::Variable + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + requires :key, type: String, desc: 'The key of the variable' + optional :value, type: String, desc: 'The value of the variable' + end + put ':id/pipeline_schedules/:pipeline_schedule_id/variables/:key' do + authorize! :update_pipeline_schedule, pipeline_schedule + + if pipeline_schedule_variable.update(declared_params(include_missing: false)) + present pipeline_schedule_variable, with: Entities::Variable + else + render_validation_error!(pipeline_schedule_variable) + end + end + + desc 'Delete a pipeline schedule variable' do + success Entities::Variable + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + requires :key, type: String, desc: 'The key of the variable' + end + delete ':id/pipeline_schedules/:pipeline_schedule_id/variables/:key' do + authorize! :admin_pipeline_schedule, pipeline_schedule + + status :accepted + present pipeline_schedule_variable.destroy, with: Entities::Variable + end end helpers do def pipeline_schedule @pipeline_schedule ||= - user_project.pipeline_schedules - .preload(:owner, :last_pipeline) - .find_by(id: params.delete(:pipeline_schedule_id)) + user_project + .pipeline_schedules + .preload(:owner, :last_pipeline) + .find_by(id: params.delete(:pipeline_schedule_id)).tap do |pipeline_schedule| + unless can?(current_user, :read_pipeline_schedule, pipeline_schedule) + not_found!('Pipeline Schedule') + end + end + end + + def pipeline_schedule_variable + @pipeline_schedule_variable ||= + pipeline_schedule.variables.find_by(key: params[:key]).tap do |pipeline_schedule_variable| + unless pipeline_schedule_variable + not_found!('Pipeline Schedule Variable') + end + end end end end diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb index e505cae3992..74b3376a1f3 100644 --- a/lib/api/pipelines.rb +++ b/lib/api/pipelines.rb @@ -7,7 +7,7 @@ module API params do requires :id, type: String, desc: 'The project ID' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get all Pipelines of the project' do detail 'This feature was introduced in GitLab 8.11.' success Entities::PipelineBasic diff --git a/lib/api/project_hooks.rb b/lib/api/project_hooks.rb index 5b457bbe639..86066e2b58f 100644 --- a/lib/api/project_hooks.rb +++ b/lib/api/project_hooks.rb @@ -24,7 +24,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get project hooks' do success Entities::ProjectHook end diff --git a/lib/api/project_milestones.rb b/lib/api/project_milestones.rb index 451998c726a..0cb209a02d0 100644 --- a/lib/api/project_milestones.rb +++ b/lib/api/project_milestones.rb @@ -10,7 +10,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get a list of project milestones' do success Entities::Milestone end diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb index 704e8c6718d..2ccda1c1aa1 100644 --- a/lib/api/project_snippets.rb +++ b/lib/api/project_snippets.rb @@ -7,7 +7,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do helpers do def handle_project_member_errors(errors) if errors[:project_access].any? diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 78d900984ac..4845242a173 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -95,7 +95,7 @@ module API end end - resource :users, requirements: { user_id: %r{[^/]+} } do + resource :users, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get a user projects' do success Entities::BasicProjectDetails end @@ -183,7 +183,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get a single project' do success Entities::ProjectWithAccess end diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb index 14d2bff9cb5..2255fb1b70d 100644 --- a/lib/api/repositories.rb +++ b/lib/api/repositories.rb @@ -9,7 +9,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do helpers do def handle_project_member_errors(errors) if errors[:project_access].any? diff --git a/lib/api/runner.rb b/lib/api/runner.rb index 11999354594..a3987c560dd 100644 --- a/lib/api/runner.rb +++ b/lib/api/runner.rb @@ -114,6 +114,8 @@ module API requires :id, type: Integer, desc: %q(Job's ID) optional :trace, type: String, desc: %q(Job's full trace) optional :state, type: String, desc: %q(Job's status: success, failed) + optional :failure_reason, type: String, values: CommitStatus.failure_reasons.keys, + desc: %q(Job's failure_reason) end put '/:id' do job = authenticate_job! @@ -127,7 +129,7 @@ module API when 'success' job.success when 'failed' - job.drop + job.drop(params[:failure_reason] || :unknown_failure) end end diff --git a/lib/api/runners.rb b/lib/api/runners.rb index 68c2120cc15..d3559ef71be 100644 --- a/lib/api/runners.rb +++ b/lib/api/runners.rb @@ -55,7 +55,9 @@ module API optional :tag_list, type: Array[String], desc: 'The list of tags for a runner' optional :run_untagged, type: Boolean, desc: 'Flag indicating the runner can execute untagged jobs' optional :locked, type: Boolean, desc: 'Flag indicating the runner is locked' - at_least_one_of :description, :active, :tag_list, :run_untagged, :locked + optional :access_level, type: String, values: Ci::Runner.access_levels.keys, + desc: 'The access_level of the runner' + at_least_one_of :description, :active, :tag_list, :run_untagged, :locked, :access_level end put ':id' do runner = get_runner(params.delete(:id)) @@ -87,7 +89,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do before { authorize_admin_project } desc 'Get runners available for project' do diff --git a/lib/api/services.rb b/lib/api/services.rb index ff9ddd44439..2cbd0517dc3 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -601,7 +601,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do before { authenticate! } before { authorize_admin_project } @@ -691,7 +691,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc "Trigger a slash command for #{service_slug}" do detail 'Added in GitLab 8.13' end diff --git a/lib/api/settings.rb b/lib/api/settings.rb index 667ba468ce6..851b226e9e5 100644 --- a/lib/api/settings.rb +++ b/lib/api/settings.rb @@ -122,6 +122,13 @@ module API optional :terminal_max_session_time, type: Integer, desc: 'Maximum time for web terminal websocket connection (in seconds). Set to 0 for unlimited time.' optional :polling_interval_multiplier, type: BigDecimal, desc: 'Interval multiplier used by endpoints that perform polling. Set to 0 to disable polling.' + ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type| + optional :"#{type}_key_restriction", + type: Integer, + values: KeyRestrictionValidator.supported_key_restrictions(type), + desc: "Restrictions on the complexity of uploaded #{type.upcase} keys. A value of #{ApplicationSetting::FORBIDDEN_KEY_VALUE} disables all #{type.upcase} keys." + end + optional(*::ApplicationSettingsHelper.visible_attributes) at_least_one_of(*::ApplicationSettingsHelper.visible_attributes) end diff --git a/lib/api/subscriptions.rb b/lib/api/subscriptions.rb index 91567909998..b3e1e23031a 100644 --- a/lib/api/subscriptions.rb +++ b/lib/api/subscriptions.rb @@ -12,7 +12,7 @@ module API requires :id, type: String, desc: 'The ID of a project' requires :subscribable_id, type: String, desc: 'The ID of a resource' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do subscribable_types.each do |type, finder| type_singularized = type.singularize entity_class = Entities.const_get(type_singularized.camelcase) diff --git a/lib/api/todos.rb b/lib/api/todos.rb index 55191169dd4..ffccfebe752 100644 --- a/lib/api/todos.rb +++ b/lib/api/todos.rb @@ -12,7 +12,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do ISSUABLE_TYPES.each do |type, finder| type_id_str = "#{type.singularize}_iid".to_sym diff --git a/lib/api/triggers.rb b/lib/api/triggers.rb index c9fee7e5193..dd6801664b1 100644 --- a/lib/api/triggers.rb +++ b/lib/api/triggers.rb @@ -5,7 +5,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Trigger a GitLab project pipeline' do success Entities::Pipeline end diff --git a/lib/api/users.rb b/lib/api/users.rb index 96f47bb618a..1825c90a23b 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -233,6 +233,86 @@ module API destroy_conditionally!(key) end + desc 'Add a GPG key to a specified user. Available only for admins.' do + detail 'This feature was added in GitLab 10.0' + success Entities::GPGKey + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + requires :key, type: String, desc: 'The new GPG key' + end + post ':id/gpg_keys' do + authenticated_as_admin! + + user = User.find_by(id: params.delete(:id)) + not_found!('User') unless user + + key = user.gpg_keys.new(declared_params(include_missing: false)) + + if key.save + present key, with: Entities::GPGKey + else + render_validation_error!(key) + end + end + + desc 'Get the GPG keys of a specified user. Available only for admins.' do + detail 'This feature was added in GitLab 10.0' + success Entities::GPGKey + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + use :pagination + end + get ':id/gpg_keys' do + authenticated_as_admin! + + user = User.find_by(id: params[:id]) + not_found!('User') unless user + + present paginate(user.gpg_keys), with: Entities::GPGKey + end + + desc 'Delete an existing GPG key from a specified user. Available only for admins.' do + detail 'This feature was added in GitLab 10.0' + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + requires :key_id, type: Integer, desc: 'The ID of the GPG key' + end + delete ':id/gpg_keys/:key_id' do + authenticated_as_admin! + + user = User.find_by(id: params[:id]) + not_found!('User') unless user + + key = user.gpg_keys.find_by(id: params[:key_id]) + not_found!('GPG Key') unless key + + status 204 + key.destroy + end + + desc 'Revokes an existing GPG key from a specified user. Available only for admins.' do + detail 'This feature was added in GitLab 10.0' + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + requires :key_id, type: Integer, desc: 'The ID of the GPG key' + end + post ':id/gpg_keys/:key_id/revoke' do + authenticated_as_admin! + + user = User.find_by(id: params[:id]) + not_found!('User') unless user + + key = user.gpg_keys.find_by(id: params[:key_id]) + not_found!('GPG Key') unless key + + key.revoke + status :accepted + end + desc 'Add an email address to a specified user. Available only for admins.' do success Entities::Email end @@ -492,6 +572,76 @@ module API destroy_conditionally!(key) end + desc "Get the currently authenticated user's GPG keys" do + detail 'This feature was added in GitLab 10.0' + success Entities::GPGKey + end + params do + use :pagination + end + get 'gpg_keys' do + present paginate(current_user.gpg_keys), with: Entities::GPGKey + end + + desc 'Get a single GPG key owned by currently authenticated user' do + detail 'This feature was added in GitLab 10.0' + success Entities::GPGKey + end + params do + requires :key_id, type: Integer, desc: 'The ID of the GPG key' + end + get 'gpg_keys/:key_id' do + key = current_user.gpg_keys.find_by(id: params[:key_id]) + not_found!('GPG Key') unless key + + present key, with: Entities::GPGKey + end + + desc 'Add a new GPG key to the currently authenticated user' do + detail 'This feature was added in GitLab 10.0' + success Entities::GPGKey + end + params do + requires :key, type: String, desc: 'The new GPG key' + end + post 'gpg_keys' do + key = current_user.gpg_keys.new(declared_params) + + if key.save + present key, with: Entities::GPGKey + else + render_validation_error!(key) + end + end + + desc 'Revoke a GPG key owned by currently authenticated user' do + detail 'This feature was added in GitLab 10.0' + end + params do + requires :key_id, type: Integer, desc: 'The ID of the GPG key' + end + post 'gpg_keys/:key_id/revoke' do + key = current_user.gpg_keys.find_by(id: params[:key_id]) + not_found!('GPG Key') unless key + + key.revoke + status :accepted + end + + desc 'Delete a GPG key from the currently authenticated user' do + detail 'This feature was added in GitLab 10.0' + end + params do + requires :key_id, type: Integer, desc: 'The ID of the SSH key' + end + delete 'gpg_keys/:key_id' do + key = current_user.gpg_keys.find_by(id: params[:key_id]) + not_found!('GPG Key') unless key + + status 204 + key.destroy + end + desc "Get the currently authenticated user's email addresses" do success Entities::Email end diff --git a/lib/api/v3/triggers.rb b/lib/api/v3/triggers.rb index e9d4c35307b..534911fde5c 100644 --- a/lib/api/v3/triggers.rb +++ b/lib/api/v3/triggers.rb @@ -16,25 +16,31 @@ module API optional :variables, type: Hash, desc: 'The list of variables to be injected into build' end post ":id/(ref/:ref/)trigger/builds", requirements: { ref: /.+/ } do - project = find_project(params[:id]) - trigger = Ci::Trigger.find_by_token(params[:token].to_s) - not_found! unless project && trigger - unauthorized! unless trigger.project == project - # validate variables - variables = params[:variables].to_h - unless variables.all? { |key, value| key.is_a?(String) && value.is_a?(String) } + params[:variables] = params[:variables].to_h + unless params[:variables].all? { |key, value| key.is_a?(String) && value.is_a?(String) } render_api_error!('variables needs to be a map of key-valued strings', 400) end - # create request and trigger builds - result = Ci::CreateTriggerRequestService.execute(project, trigger, params[:ref].to_s, variables) - pipeline = result.pipeline + project = find_project(params[:id]) + not_found! unless project + + result = Ci::PipelineTriggerService.new(project, nil, params).execute + not_found! unless result - if pipeline.persisted? - present result.trigger_request, with: ::API::V3::Entities::TriggerRequest + if result[:http_status] + render_api_error!(result[:message], result[:http_status]) else - render_validation_error!(pipeline) + pipeline = result[:pipeline] + + # We switched to Ci::PipelineVariable from Ci::TriggerRequest.variables. + # Ci::TriggerRequest doesn't save variables anymore. + # Here is copying Ci::PipelineVariable to Ci::TriggerRequest.variables for presenting the variables. + # The same endpoint in v4 API pressents Pipeline instead of TriggerRequest, so it doesn't need such a process. + trigger_request = pipeline.trigger_requests.last + trigger_request.variables = params[:variables] + + present trigger_request, with: ::API::V3::Entities::TriggerRequest end end diff --git a/lib/api/variables.rb b/lib/api/variables.rb index da71787abab..d08876ae1b9 100644 --- a/lib/api/variables.rb +++ b/lib/api/variables.rb @@ -9,7 +9,7 @@ module API requires :id, type: String, desc: 'The ID of a project' end - resource :projects, requirements: { id: %r{[^/]+} } do + resource :projects, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do desc 'Get project variables' do success Entities::Variable end diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 3a4911b23b0..62b44389b15 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -20,24 +20,6 @@ module Ci raise ValidationError, e.message end - def jobs_for_ref(ref, tag = false, source = nil) - @jobs.select do |_, job| - process?(job[:only], job[:except], ref, tag, source) - end - end - - def jobs_for_stage_and_ref(stage, ref, tag = false, source = nil) - jobs_for_ref(ref, tag, source).select do |_, job| - job[:stage] == stage - end - end - - def builds_for_ref(ref, tag = false, source = nil) - jobs_for_ref(ref, tag, source).map do |name, _| - build_attributes(name) - end - end - def builds_for_stage_and_ref(stage, ref, tag = false, source = nil) jobs_for_stage_and_ref(stage, ref, tag, source).map do |name, _| build_attributes(name) @@ -52,8 +34,7 @@ module Ci def stage_seeds(pipeline) seeds = @stages.uniq.map do |stage| - builds = builds_for_stage_and_ref( - stage, pipeline.ref, pipeline.tag?, pipeline.source) + builds = pipeline_stage_builds(stage, pipeline) Gitlab::Ci::Stage::Seed.new(pipeline, stage, builds) if builds.any? end @@ -101,6 +82,34 @@ module Ci private + def pipeline_stage_builds(stage, pipeline) + builds = builds_for_stage_and_ref( + stage, pipeline.ref, pipeline.tag?, pipeline.source) + + builds.select do |build| + job = @jobs[build.fetch(:name).to_sym] + has_kubernetes = pipeline.has_kubernetes_active? + only_kubernetes = job.dig(:only, :kubernetes) + except_kubernetes = job.dig(:except, :kubernetes) + + [!only_kubernetes && !except_kubernetes, + only_kubernetes && has_kubernetes, + except_kubernetes && !has_kubernetes].any? + end + end + + def jobs_for_ref(ref, tag = false, source = nil) + @jobs.select do |_, job| + process?(job.dig(:only, :refs), job.dig(:except, :refs), ref, tag, source) + end + end + + def jobs_for_stage_and_ref(stage, ref, tag = false, source = nil) + jobs_for_ref(ref, tag, source).select do |_, job| + job[:stage] == stage + end + end + def initial_parsing ## # Global config diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 1790f380c33..3fd81759d25 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -50,10 +50,6 @@ module Gitlab # Avoid resource intensive login checks if password is not provided return unless password.present? - # Nothing to do here if internal auth is disabled and LDAP is - # not configured - return unless current_application_settings.password_authentication_enabled? || Gitlab::LDAP::Config.enabled? - Gitlab::Auth::UniqueIpsLimiter.limit_user! do user = User.by_login(login) diff --git a/lib/gitlab/ci/config/entry/policy.rb b/lib/gitlab/ci/config/entry/policy.rb index 3cdae1cee4f..0027e9ec8c5 100644 --- a/lib/gitlab/ci/config/entry/policy.rb +++ b/lib/gitlab/ci/config/entry/policy.rb @@ -7,6 +7,7 @@ module Gitlab # class Policy < Simplifiable strategy :RefsPolicy, if: -> (config) { config.is_a?(Array) } + strategy :ComplexPolicy, if: -> (config) { config.is_a?(Hash) } class RefsPolicy < Entry::Node include Entry::Validatable @@ -14,6 +15,27 @@ module Gitlab validations do validates :config, array_of_strings_or_regexps: true end + + def value + { refs: @config } + end + end + + class ComplexPolicy < Entry::Node + include Entry::Validatable + include Entry::Attributable + + attributes :refs, :kubernetes + + validations do + validates :config, presence: true + validates :config, allowed_keys: %i[refs kubernetes] + + with_options allow_nil: true do + validates :refs, array_of_strings_or_regexps: true + validates :kubernetes, allowed_values: %w[active] + end + end end class UnknownStrategy < Entry::Node diff --git a/lib/gitlab/ci/config/entry/validators.rb b/lib/gitlab/ci/config/entry/validators.rb index b2ca3c881e4..0159179f0a9 100644 --- a/lib/gitlab/ci/config/entry/validators.rb +++ b/lib/gitlab/ci/config/entry/validators.rb @@ -14,6 +14,14 @@ module Gitlab end end + class AllowedValuesValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + unless options[:in].include?(value.to_s) + record.errors.add(attribute, "unknown value: #{value}") + end + end + end + class ArrayOfStringsValidator < ActiveModel::EachValidator include LegacyValidationHelpers diff --git a/lib/gitlab/ci/stage/seed.rb b/lib/gitlab/ci/stage/seed.rb index f81f9347b4d..e19aae35a81 100644 --- a/lib/gitlab/ci/stage/seed.rb +++ b/lib/gitlab/ci/stage/seed.rb @@ -28,7 +28,8 @@ module Gitlab attributes.merge(project: project, ref: pipeline.ref, tag: pipeline.tag, - trigger_request: trigger) + trigger_request: trigger, + protected: protected_ref?) end end @@ -43,6 +44,12 @@ module Gitlab end end end + + private + + def protected_ref? + @protected_ref ||= project.protected_for?(pipeline.ref) + end end end end diff --git a/lib/gitlab/conflict/file_collection.rb b/lib/gitlab/conflict/file_collection.rb index d671867e7c7..90f83e0f810 100644 --- a/lib/gitlab/conflict/file_collection.rb +++ b/lib/gitlab/conflict/file_collection.rb @@ -18,7 +18,7 @@ module Gitlab new(merge_request, project).tap do |file_collection| project .repository - .with_repo_branch_commit(merge_request.target_project.repository, merge_request.target_branch) do + .with_repo_branch_commit(merge_request.target_project.repository.raw_repository, merge_request.target_branch) do yield file_collection end diff --git a/lib/gitlab/git.rb b/lib/gitlab/git.rb index b6449f27034..8c9acbc9fbe 100644 --- a/lib/gitlab/git.rb +++ b/lib/gitlab/git.rb @@ -5,6 +5,7 @@ module Gitlab BRANCH_REF_PREFIX = "refs/heads/".freeze CommandError = Class.new(StandardError) + CommitError = Class.new(StandardError) class << self include Gitlab::EncodingHelper diff --git a/lib/gitlab/git/operation_service.rb b/lib/gitlab/git/operation_service.rb new file mode 100644 index 00000000000..9e6fca8c80c --- /dev/null +++ b/lib/gitlab/git/operation_service.rb @@ -0,0 +1,168 @@ +module Gitlab + module Git + class OperationService + attr_reader :committer, :repository + + def initialize(committer, new_repository) + committer = Gitlab::Git::Committer.from_user(committer) if committer.is_a?(User) + @committer = committer + + # Refactoring aid + unless new_repository.is_a?(Gitlab::Git::Repository) + raise "expected a Gitlab::Git::Repository, got #{new_repository}" + end + + @repository = new_repository + end + + def add_branch(branch_name, newrev) + ref = Gitlab::Git::BRANCH_REF_PREFIX + branch_name + oldrev = Gitlab::Git::BLANK_SHA + + update_ref_in_hooks(ref, newrev, oldrev) + end + + def rm_branch(branch) + ref = Gitlab::Git::BRANCH_REF_PREFIX + branch.name + oldrev = branch.target + newrev = Gitlab::Git::BLANK_SHA + + update_ref_in_hooks(ref, newrev, oldrev) + end + + def add_tag(tag_name, newrev, options = {}) + ref = Gitlab::Git::TAG_REF_PREFIX + tag_name + oldrev = Gitlab::Git::BLANK_SHA + + with_hooks(ref, newrev, oldrev) do |service| + # We want to pass the OID of the tag object to the hooks. For an + # annotated tag we don't know that OID until after the tag object + # (raw_tag) is created in the repository. That is why we have to + # update the value after creating the tag object. Only the + # "post-receive" hook will receive the correct value in this case. + raw_tag = repository.rugged.tags.create(tag_name, newrev, options) + service.newrev = raw_tag.target_id + end + end + + def rm_tag(tag) + ref = Gitlab::Git::TAG_REF_PREFIX + tag.name + oldrev = tag.target + newrev = Gitlab::Git::BLANK_SHA + + update_ref_in_hooks(ref, newrev, oldrev) do + repository.rugged.tags.delete(tag_name) + end + end + + # Whenever `start_branch_name` is passed, if `branch_name` doesn't exist, + # it would be created from `start_branch_name`. + # If `start_project` is passed, and the branch doesn't exist, + # it would try to find the commits from it instead of current repository. + def with_branch( + branch_name, + start_branch_name: nil, + start_repository: repository, + &block) + + # Refactoring aid + unless start_repository.is_a?(Gitlab::Git::Repository) + raise "expected a Gitlab::Git::Repository, got #{start_repository}" + end + + start_branch_name = nil if start_repository.empty_repo? + + if start_branch_name && !start_repository.branch_exists?(start_branch_name) + raise ArgumentError, "Cannot find branch #{start_branch_name} in #{start_repository.full_path}" + end + + update_branch_with_hooks(branch_name) do + repository.with_repo_branch_commit( + start_repository, + start_branch_name || branch_name, + &block) + end + end + + private + + # Returns [newrev, should_run_after_create, should_run_after_create_branch] + def update_branch_with_hooks(branch_name) + update_autocrlf_option + + was_empty = repository.empty? + + # Make commit + newrev = yield + + unless newrev + raise Gitlab::Git::CommitError.new('Failed to create commit') + end + + branch = repository.find_branch(branch_name) + oldrev = find_oldrev_from_branch(newrev, branch) + + ref = Gitlab::Git::BRANCH_REF_PREFIX + branch_name + update_ref_in_hooks(ref, newrev, oldrev) + + [newrev, was_empty, was_empty || Gitlab::Git.blank_ref?(oldrev)] + end + + def find_oldrev_from_branch(newrev, branch) + return Gitlab::Git::BLANK_SHA unless branch + + oldrev = branch.target + + if oldrev == repository.rugged.merge_base(newrev, branch.target) + oldrev + else + raise Gitlab::Git::CommitError.new('Branch diverged') + end + end + + def update_ref_in_hooks(ref, newrev, oldrev) + with_hooks(ref, newrev, oldrev) do + update_ref(ref, newrev, oldrev) + end + end + + def with_hooks(ref, newrev, oldrev) + Gitlab::Git::HooksService.new.execute( + committer, + repository, + oldrev, + newrev, + ref) do |service| + + yield(service) + end + end + + # Gitaly note: JV: wait with migrating #update_ref until we know how to migrate its call sites. + def update_ref(ref, newrev, oldrev) + # We use 'git update-ref' because libgit2/rugged currently does not + # offer 'compare and swap' ref updates. Without compare-and-swap we can + # (and have!) accidentally reset the ref to an earlier state, clobbering + # commits. See also https://github.com/libgit2/libgit2/issues/1534. + command = %W[#{Gitlab.config.git.bin_path} update-ref --stdin -z] + _, status = Gitlab::Popen.popen( + command, + repository.path) do |stdin| + stdin.write("update #{ref}\x00#{newrev}\x00#{oldrev}\x00") + end + + unless status.zero? + raise Gitlab::Git::CommitError.new( + "Could not update branch #{Gitlab::Git.branch_name(ref)}." \ + " Please refresh and try again.") + end + end + + def update_autocrlf_option + if repository.autocrlf != :input + repository.autocrlf = :input + end + end + end + end +end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 554e40dc8a6..75d4efc0bc5 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -73,6 +73,10 @@ module Gitlab delegate :exists?, to: :gitaly_repository_client + def ==(other) + path == other.path + end + # Default branch in the repository def root_ref @root_ref ||= gitaly_migrate(:root_ref) do |is_enabled| @@ -130,15 +134,19 @@ module Gitlab # This is to work around a bug in libgit2 that causes in-memory refs to # be stale/invalid when packed-refs is changed. # See https://gitlab.com/gitlab-org/gitlab-ce/issues/15392#note_14538333 - # - # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/474 def find_branch(name, force_reload = false) - reload_rugged if force_reload + gitaly_migrate(:find_branch) do |is_enabled| + if is_enabled + gitaly_ref_client.find_branch(name) + else + reload_rugged if force_reload - rugged_ref = rugged.branches[name] - if rugged_ref - target_commit = Gitlab::Git::Commit.find(self, rugged_ref.target) - Gitlab::Git::Branch.new(self, rugged_ref.name, rugged_ref.target, target_commit) + rugged_ref = rugged.branches[name] + if rugged_ref + target_commit = Gitlab::Git::Commit.find(self, rugged_ref.target) + Gitlab::Git::Branch.new(self, rugged_ref.name, rugged_ref.target, target_commit) + end + end end end @@ -250,11 +258,17 @@ module Gitlab branch_names + tag_names end + def delete_all_refs_except(prefixes) + delete_refs(*all_ref_names_except(prefixes)) + end + # Returns an Array of all ref names, except when it's matching pattern # # regexp - The pattern for ref names we don't want - def all_ref_names_except(regexp) - rugged.references.reject { |ref| ref.name =~ regexp }.map(&:name) + def all_ref_names_except(prefixes) + rugged.references.reject do |ref| + prefixes.any? { |p| ref.name.start_with?(p) } + end.map(&:name) end # Discovers the default branch based on the repository's available branches @@ -595,6 +609,49 @@ module Gitlab # TODO: implement this method end + def add_branch(branch_name, committer:, target:) + target_object = Ref.dereference_object(lookup(target)) + raise InvalidRef.new("target not found: #{target}") unless target_object + + OperationService.new(committer, self).add_branch(branch_name, target_object.oid) + find_branch(branch_name) + rescue Rugged::ReferenceError => ex + raise InvalidRef, ex + end + + def add_tag(tag_name, committer:, target:, message: nil) + target_object = Ref.dereference_object(lookup(target)) + raise InvalidRef.new("target not found: #{target}") unless target_object + + committer = Committer.from_user(committer) if committer.is_a?(User) + + options = nil # Use nil, not the empty hash. Rugged cares about this. + if message + options = { + message: message, + tagger: Gitlab::Git.committer_hash(email: committer.email, name: committer.name) + } + end + + OperationService.new(committer, self).add_tag(tag_name, target_object.oid, options) + + find_tag(tag_name) + rescue Rugged::ReferenceError => ex + raise InvalidRef, ex + end + + def rm_branch(branch_name, committer:) + OperationService.new(committer, self).rm_branch(find_branch(branch_name)) + end + + def rm_tag(tag_name, committer:) + OperationService.new(committer, self).rm_tag(find_tag(tag_name)) + end + + def find_tag(name) + tags.find { |tag| tag.name == name } + end + # Delete the specified branch from the repository # # Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/476 @@ -734,6 +791,106 @@ module Gitlab end end + def with_repo_branch_commit(start_repository, start_branch_name) + raise "expected Gitlab::Git::Repository, got #{start_repository}" unless start_repository.is_a?(Gitlab::Git::Repository) + + return yield nil if start_repository.empty_repo? + + if start_repository == self + yield commit(start_branch_name) + else + sha = start_repository.commit(start_branch_name).sha + + if branch_commit = commit(sha) + yield branch_commit + else + with_repo_tmp_commit( + start_repository, start_branch_name, sha) do |tmp_commit| + yield tmp_commit + end + end + end + end + + def with_repo_tmp_commit(start_repository, start_branch_name, sha) + tmp_ref = fetch_ref( + start_repository.path, + "#{Gitlab::Git::BRANCH_REF_PREFIX}#{start_branch_name}", + "refs/tmp/#{SecureRandom.hex}/head" + ) + + yield commit(sha) + ensure + delete_refs(tmp_ref) if tmp_ref + end + + def fetch_source_branch(source_repository, source_branch, local_ref) + with_repo_branch_commit(source_repository, source_branch) do |commit| + if commit + write_ref(local_ref, commit.sha) + else + raise Rugged::ReferenceError, 'source repository is empty' + end + end + end + + def compare_source_branch(target_branch_name, source_repository, source_branch_name, straight:) + with_repo_branch_commit(source_repository, source_branch_name) do |commit| + break unless commit + + Gitlab::Git::Compare.new( + self, + target_branch_name, + commit.sha, + straight: straight + ) + end + end + + def write_ref(ref_path, sha) + rugged.references.create(ref_path, sha, force: true) + end + + def fetch_ref(source_path, source_ref, target_ref) + args = %W(fetch --no-tags -f #{source_path} #{source_ref}:#{target_ref}) + message, status = run_git(args) + + # Make sure ref was created, and raise Rugged::ReferenceError when not + raise Rugged::ReferenceError, message if status != 0 + + target_ref + end + + # Refactoring aid; allows us to copy code from app/models/repository.rb + def run_git(args) + circuit_breaker.perform do + popen([Gitlab.config.git.bin_path, *args], path) + end + end + + # Refactoring aid; allows us to copy code from app/models/repository.rb + def commit(ref = 'HEAD') + Gitlab::Git::Commit.find(self, ref) + end + + # Refactoring aid; allows us to copy code from app/models/repository.rb + def empty_repo? + !exists? || !has_visible_content? + end + + # + # Git repository can contains some hidden refs like: + # /refs/notes/* + # /refs/git-as-svn/* + # /refs/pulls/* + # This refs by default not visible in project page and not cloned to client side. + # + # This method return true if repository contains some content visible in project page. + # + def has_visible_content? + branch_count > 0 + end + def gitaly_repository Gitlab::GitalyClient::Util.repository(@storage, @relative_path) end diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb index 3e8b83c0f90..62d1ecae676 100644 --- a/lib/gitlab/git_access.rb +++ b/lib/gitlab/git_access.rb @@ -35,6 +35,7 @@ module Gitlab def check(cmd, changes) check_protocol! + check_valid_actor! check_active_user! check_project_accessibility! check_project_moved! @@ -70,6 +71,14 @@ module Gitlab private + def check_valid_actor! + return unless actor.is_a?(Key) + + unless actor.valid? + raise UnauthorizedError, "Your SSH key #{actor.errors[:key].first}." + end + end + def check_protocol! unless protocol_allowed? raise UnauthorizedError, "Git access over #{protocol.upcase} is not allowed" diff --git a/lib/gitlab/gitaly_client/ref_service.rb b/lib/gitlab/gitaly_client/ref_service.rb index 8c0008c6971..a1a25cf2079 100644 --- a/lib/gitlab/gitaly_client/ref_service.rb +++ b/lib/gitlab/gitaly_client/ref_service.rb @@ -78,6 +78,20 @@ module Gitlab raise ArgumentError, e.message end + def find_branch(branch_name) + request = Gitaly::DeleteBranchRequest.new( + repository: @gitaly_repo, + name: GitalyClient.encode(branch_name) + ) + + response = GitalyClient.call(@repository.storage, :ref_service, :find_branch, request) + branch = response.branch + return unless branch + + target_commit = Gitlab::Git::Commit.decorate(@repository, branch.target_commit) + Gitlab::Git::Branch.new(@repository, encode!(branch.name.dup), branch.target_commit.id, target_commit) + end + private def consume_refs_response(response) diff --git a/lib/gitlab/gpg.rb b/lib/gitlab/gpg.rb index 45e9f9d65ae..025f826e65f 100644 --- a/lib/gitlab/gpg.rb +++ b/lib/gitlab/gpg.rb @@ -39,7 +39,7 @@ module Gitlab fingerprints = CurrentKeyChain.fingerprints_from_key(key) GPGME::Key.find(:public, fingerprints).flat_map do |raw_key| - raw_key.uids.map { |uid| { name: uid.name, email: uid.email } } + raw_key.uids.map { |uid| { name: uid.name, email: uid.email.downcase } } end end end diff --git a/lib/gitlab/gpg/commit.rb b/lib/gitlab/gpg/commit.rb index 606c7576f70..86bd9f5b125 100644 --- a/lib/gitlab/gpg/commit.rb +++ b/lib/gitlab/gpg/commit.rb @@ -1,17 +1,12 @@ module Gitlab module Gpg class Commit - def self.for_commit(commit) - new(commit.project, commit.sha) - end - - def initialize(project, sha) - @project = project - @sha = sha + def initialize(commit) + @commit = commit @signature_text, @signed_text = begin - Rugged::Commit.extract_signature(project.repository.rugged, sha) + Rugged::Commit.extract_signature(@commit.project.repository.rugged, @commit.sha) rescue Rugged::OdbError nil end @@ -26,7 +21,7 @@ module Gitlab return @signature if @signature - cached_signature = GpgSignature.find_by(commit_sha: @sha) + cached_signature = GpgSignature.find_by(commit_sha: @commit.sha) return @signature = cached_signature if cached_signature.present? @signature = create_cached_signature! @@ -73,20 +68,31 @@ module Gitlab def attributes(gpg_key) user_infos = user_infos(gpg_key) + verification_status = verification_status(gpg_key) { - commit_sha: @sha, - project: @project, + commit_sha: @commit.sha, + project: @commit.project, gpg_key: gpg_key, gpg_key_primary_keyid: gpg_key&.primary_keyid || verified_signature.fingerprint, gpg_key_user_name: user_infos[:name], gpg_key_user_email: user_infos[:email], - valid_signature: gpg_signature_valid_signature_value(gpg_key) + verification_status: verification_status } end - def gpg_signature_valid_signature_value(gpg_key) - !!(gpg_key && gpg_key.verified? && verified_signature.valid?) + def verification_status(gpg_key) + return :unknown_key unless gpg_key + return :unverified_key unless gpg_key.verified? + return :unverified unless verified_signature.valid? + + if gpg_key.verified_and_belongs_to_email?(@commit.committer_email) + :verified + elsif gpg_key.user.all_emails.include?(@commit.committer_email) + :same_user_different_email + else + :other_user + end end def user_infos(gpg_key) diff --git a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb index a525ee7a9ee..e085eab26c9 100644 --- a/lib/gitlab/gpg/invalid_gpg_signature_updater.rb +++ b/lib/gitlab/gpg/invalid_gpg_signature_updater.rb @@ -8,7 +8,7 @@ module Gitlab def run GpgSignature .select(:id, :commit_sha, :project_id) - .where('gpg_key_id IS NULL OR valid_signature = ?', false) + .where('gpg_key_id IS NULL OR verification_status <> ?', GpgSignature.verification_statuses[:verified]) .where(gpg_key_primary_keyid: @gpg_key.primary_keyid) .find_each { |sig| sig.gpg_commit.update_signature!(sig) } end diff --git a/lib/gitlab/i18n/metadata_entry.rb b/lib/gitlab/i18n/metadata_entry.rb new file mode 100644 index 00000000000..35d57459a3d --- /dev/null +++ b/lib/gitlab/i18n/metadata_entry.rb @@ -0,0 +1,27 @@ +module Gitlab + module I18n + class MetadataEntry + attr_reader :entry_data + + def initialize(entry_data) + @entry_data = entry_data + end + + def expected_plurals + return nil unless plural_information + + plural_information['nplurals'].to_i + end + + private + + def plural_information + return @plural_information if defined?(@plural_information) + + if plural_line = entry_data[:msgstr].detect { |metadata_line| metadata_line.starts_with?('Plural-Forms: ') } + @plural_information = Hash[plural_line.scan(/(\w+)=([^;\n]+)/)] + end + end + end + end +end diff --git a/lib/gitlab/i18n/po_linter.rb b/lib/gitlab/i18n/po_linter.rb new file mode 100644 index 00000000000..7d3ff8c7f58 --- /dev/null +++ b/lib/gitlab/i18n/po_linter.rb @@ -0,0 +1,214 @@ +module Gitlab + module I18n + class PoLinter + attr_reader :po_path, :translation_entries, :metadata_entry, :locale + + VARIABLE_REGEX = /%{\w*}|%[a-z]/.freeze + + def initialize(po_path, locale = I18n.locale.to_s) + @po_path = po_path + @locale = locale + end + + def errors + @errors ||= validate_po + end + + def validate_po + if parse_error = parse_po + return 'PO-syntax errors' => [parse_error] + end + + validate_entries + end + + def parse_po + entries = SimplePoParser.parse(po_path) + + # The first entry is the metadata entry if there is one. + # This is an entry when empty `msgid` + if entries.first[:msgid].empty? + @metadata_entry = Gitlab::I18n::MetadataEntry.new(entries.shift) + else + return 'Missing metadata entry.' + end + + @translation_entries = entries.map do |entry_data| + Gitlab::I18n::TranslationEntry.new(entry_data, metadata_entry.expected_plurals) + end + + nil + rescue SimplePoParser::ParserError => e + @translation_entries = [] + e.message + end + + def validate_entries + errors = {} + + translation_entries.each do |entry| + errors_for_entry = validate_entry(entry) + errors[join_message(entry.msgid)] = errors_for_entry if errors_for_entry.any? + end + + errors + end + + def validate_entry(entry) + errors = [] + + validate_flags(errors, entry) + validate_variables(errors, entry) + validate_newlines(errors, entry) + validate_number_of_plurals(errors, entry) + validate_unescaped_chars(errors, entry) + + errors + end + + def validate_unescaped_chars(errors, entry) + if entry.msgid_contains_unescaped_chars? + errors << 'contains unescaped `%`, escape it using `%%`' + end + + if entry.plural_id_contains_unescaped_chars? + errors << 'plural id contains unescaped `%`, escape it using `%%`' + end + + if entry.translations_contain_unescaped_chars? + errors << 'translation contains unescaped `%`, escape it using `%%`' + end + end + + def validate_number_of_plurals(errors, entry) + return unless metadata_entry&.expected_plurals + return unless entry.translated? + + if entry.has_plural? && entry.all_translations.size != metadata_entry.expected_plurals + errors << "should have #{metadata_entry.expected_plurals} "\ + "#{'translations'.pluralize(metadata_entry.expected_plurals)}" + end + end + + def validate_newlines(errors, entry) + if entry.msgid_contains_newlines? + errors << 'is defined over multiple lines, this breaks some tooling.' + end + + if entry.plural_id_contains_newlines? + errors << 'plural is defined over multiple lines, this breaks some tooling.' + end + + if entry.translations_contain_newlines? + errors << 'has translations defined over multiple lines, this breaks some tooling.' + end + end + + def validate_variables(errors, entry) + if entry.has_singular_translation? + validate_variables_in_message(errors, entry.msgid, entry.singular_translation) + end + + if entry.has_plural? + entry.plural_translations.each do |translation| + validate_variables_in_message(errors, entry.plural_id, translation) + end + end + end + + def validate_variables_in_message(errors, message_id, message_translation) + message_id = join_message(message_id) + required_variables = message_id.scan(VARIABLE_REGEX) + + validate_unnamed_variables(errors, required_variables) + validate_translation(errors, message_id, required_variables) + validate_variable_usage(errors, message_translation, required_variables) + end + + def validate_translation(errors, message_id, used_variables) + variables = fill_in_variables(used_variables) + + begin + Gitlab::I18n.with_locale(locale) do + translated = if message_id.include?('|') + FastGettext::Translation.s_(message_id) + else + FastGettext::Translation._(message_id) + end + + translated % variables + end + + # `sprintf` could raise an `ArgumentError` when invalid passing something + # other than a Hash when using named variables + # + # `sprintf` could raise `TypeError` when passing a wrong type when using + # unnamed variables + # + # FastGettext::Translation could raise `RuntimeError` (raised as a string), + # or as subclassess `NoTextDomainConfigured` & `InvalidFormat` + # + # `FastGettext::Translation` could raise `ArgumentError` as subclassess + # `InvalidEncoding`, `IllegalSequence` & `InvalidCharacter` + rescue ArgumentError, TypeError, RuntimeError => e + errors << "Failure translating to #{locale} with #{variables}: #{e.message}" + end + end + + def fill_in_variables(variables) + if variables.empty? + [] + elsif variables.any? { |variable| unnamed_variable?(variable) } + variables.map do |variable| + variable == '%d' ? Random.rand(1000) : Gitlab::Utils.random_string + end + else + variables.inject({}) do |hash, variable| + variable_name = variable[/\w+/] + hash[variable_name] = Gitlab::Utils.random_string + hash + end + end + end + + def validate_unnamed_variables(errors, variables) + if variables.size > 1 && variables.any? { |variable_name| unnamed_variable?(variable_name) } + errors << 'is combining multiple unnamed variables' + end + end + + def validate_variable_usage(errors, translation, required_variables) + translation = join_message(translation) + + # We don't need to validate when the message is empty. + # In this case we fall back to the default, which has all the the + # required variables. + return if translation.empty? + + found_variables = translation.scan(VARIABLE_REGEX) + + missing_variables = required_variables - found_variables + if missing_variables.any? + errors << "<#{translation}> is missing: [#{missing_variables.to_sentence}]" + end + + unknown_variables = found_variables - required_variables + if unknown_variables.any? + errors << "<#{translation}> is using unknown variables: [#{unknown_variables.to_sentence}]" + end + end + + def unnamed_variable?(variable_name) + !variable_name.start_with?('%{') + end + + def validate_flags(errors, entry) + errors << "is marked #{entry.flag}" if entry.flag + end + + def join_message(message) + Array(message).join + end + end + end +end diff --git a/lib/gitlab/i18n/translation_entry.rb b/lib/gitlab/i18n/translation_entry.rb new file mode 100644 index 00000000000..e6c95afca7e --- /dev/null +++ b/lib/gitlab/i18n/translation_entry.rb @@ -0,0 +1,92 @@ +module Gitlab + module I18n + class TranslationEntry + PERCENT_REGEX = /(?:^|[^%])%(?!{\w*}|[a-z%])/.freeze + + attr_reader :nplurals, :entry_data + + def initialize(entry_data, nplurals) + @entry_data = entry_data + @nplurals = nplurals + end + + def msgid + entry_data[:msgid] + end + + def plural_id + entry_data[:msgid_plural] + end + + def has_plural? + plural_id.present? + end + + def singular_translation + all_translations.first if has_singular_translation? + end + + def all_translations + @all_translations ||= entry_data.fetch_values(*translation_keys) + .reject(&:empty?) + end + + def translated? + all_translations.any? + end + + def plural_translations + return [] unless has_plural? + return [] unless translated? + + @plural_translations ||= if has_singular_translation? + all_translations.drop(1) + else + all_translations + end + end + + def flag + entry_data[:flag] + end + + def has_singular_translation? + nplurals > 1 || !has_plural? + end + + def msgid_contains_newlines? + msgid.is_a?(Array) + end + + def plural_id_contains_newlines? + plural_id.is_a?(Array) + end + + def translations_contain_newlines? + all_translations.any? { |translation| translation.is_a?(Array) } + end + + def msgid_contains_unescaped_chars? + contains_unescaped_chars?(msgid) + end + + def plural_id_contains_unescaped_chars? + contains_unescaped_chars?(plural_id) + end + + def translations_contain_unescaped_chars? + all_translations.any? { |translation| contains_unescaped_chars?(translation) } + end + + def contains_unescaped_chars?(string) + string =~ PERCENT_REGEX + end + + private + + def translation_keys + @translation_keys ||= entry_data.keys.select { |key| key.to_s =~ /\Amsgstr(\[\d+\])?\z/ } + end + end + end +end diff --git a/lib/gitlab/issuables_count_for_state.rb b/lib/gitlab/issuables_count_for_state.rb new file mode 100644 index 00000000000..505810964bc --- /dev/null +++ b/lib/gitlab/issuables_count_for_state.rb @@ -0,0 +1,50 @@ +module Gitlab + # Class for counting and caching the number of issuables per state. + class IssuablesCountForState + # The name of the RequestStore cache key. + CACHE_KEY = :issuables_count_for_state + + # The state values that can be safely casted to a Symbol. + STATES = %w[opened closed merged all].freeze + + # finder - The finder class to use for retrieving the issuables. + def initialize(finder) + @finder = finder + @cache = + if RequestStore.active? + RequestStore[CACHE_KEY] ||= initialize_cache + else + initialize_cache + end + end + + def for_state_or_opened(state = nil) + self[state || :opened] + end + + # Returns the count for the given state. + # + # state - The name of the state as either a String or a Symbol. + # + # Returns an Integer. + def [](state) + state = state.to_sym if cast_state_to_symbol?(state) + + cache_for_finder[state] || 0 + end + + private + + def cache_for_finder + @cache[@finder] + end + + def cast_state_to_symbol?(state) + state.is_a?(String) && STATES.include?(state) + end + + def initialize_cache + Hash.new { |hash, finder| hash[finder] = finder.count_by_state } + end + end +end diff --git a/lib/gitlab/key_fingerprint.rb b/lib/gitlab/key_fingerprint.rb deleted file mode 100644 index d9a79f7c291..00000000000 --- a/lib/gitlab/key_fingerprint.rb +++ /dev/null @@ -1,48 +0,0 @@ -module Gitlab - class KeyFingerprint - attr_reader :key, :ssh_key - - # Unqualified MD5 fingerprint for compatibility - delegate :fingerprint, to: :ssh_key, allow_nil: true - - def initialize(key) - @key = key - - @ssh_key = - begin - Net::SSH::KeyFactory.load_data_public_key(key) - rescue Net::SSH::Exception, NotImplementedError - end - end - - def valid? - ssh_key.present? - end - - def type - return unless valid? - - parts = ssh_key.ssh_type.split('-') - parts.shift if parts[0] == 'ssh' - - parts[0].upcase - end - - def bits - return unless valid? - - case type - when 'RSA' - ssh_key.n.num_bits - when 'DSS', 'DSA' - ssh_key.p.num_bits - when 'ECDSA' - ssh_key.group.order.num_bits - when 'ED25519' - 256 - else - raise "Unsupported key type: #{type}" - end - end - end -end diff --git a/lib/gitlab/reference_counter.rb b/lib/gitlab/reference_counter.rb new file mode 100644 index 00000000000..bb26f1b610a --- /dev/null +++ b/lib/gitlab/reference_counter.rb @@ -0,0 +1,44 @@ +module Gitlab + class ReferenceCounter + REFERENCE_EXPIRE_TIME = 600 + + attr_reader :gl_repository, :key + + def initialize(gl_repository) + @gl_repository = gl_repository + @key = "git-receive-pack-reference-counter:#{gl_repository}" + end + + def value + Gitlab::Redis::SharedState.with { |redis| (redis.get(key) || 0).to_i } + end + + def increase + redis_cmd do |redis| + redis.incr(key) + redis.expire(key, REFERENCE_EXPIRE_TIME) + end + end + + def decrease + redis_cmd do |redis| + current_value = redis.decr(key) + if current_value < 0 + Rails.logger.warn("Reference counter for #{gl_repository} decreased" \ + " when its value was less than 1. Reseting the counter.") + redis.del(key) + end + end + end + + private + + def redis_cmd + Gitlab::Redis::SharedState.with { |redis| yield(redis) } + true + rescue => e + Rails.logger.warn("GitLab: An unexpected error occurred in writing to Redis: #{e}") + false + end + end +end diff --git a/lib/gitlab/sentry.rb b/lib/gitlab/sentry.rb index f6bdd6cf0fe..159d0e7952e 100644 --- a/lib/gitlab/sentry.rb +++ b/lib/gitlab/sentry.rb @@ -9,6 +9,8 @@ module Gitlab def self.context(current_user = nil) return unless self.enabled? + Raven.tags_context(locale: I18n.locale) + if current_user Raven.user_context( id: current_user.id, diff --git a/lib/gitlab/sql/pattern.rb b/lib/gitlab/sql/pattern.rb index b42bc67ccfc..7c2d1d8f887 100644 --- a/lib/gitlab/sql/pattern.rb +++ b/lib/gitlab/sql/pattern.rb @@ -4,6 +4,7 @@ module Gitlab extend ActiveSupport::Concern MIN_CHARS_FOR_PARTIAL_MATCHING = 3 + REGEX_QUOTED_WORD = /(?<=^| )"[^"]+"(?= |$)/ class_methods do def to_pattern(query) @@ -17,6 +18,28 @@ module Gitlab def partial_matching?(query) query.length >= MIN_CHARS_FOR_PARTIAL_MATCHING end + + def to_fuzzy_arel(column, query) + words = select_fuzzy_words(query) + + matches = words.map { |word| arel_table[column].matches(to_pattern(word)) } + + matches.reduce { |result, match| result.and(match) } + end + + def select_fuzzy_words(query) + quoted_words = query.scan(REGEX_QUOTED_WORD) + + query = quoted_words.reduce(query) { |q, quoted_word| q.sub(quoted_word, '') } + + words = query.split(/\s+/) + + quoted_words.map! { |quoted_word| quoted_word[1..-2] } + + words.concat(quoted_words) + + words.select { |word| partial_matching?(word) } + end end end end diff --git a/lib/gitlab/ssh_public_key.rb b/lib/gitlab/ssh_public_key.rb new file mode 100644 index 00000000000..89ca1298120 --- /dev/null +++ b/lib/gitlab/ssh_public_key.rb @@ -0,0 +1,71 @@ +module Gitlab + class SSHPublicKey + Technology = Struct.new(:name, :key_class, :supported_sizes) + + Technologies = [ + Technology.new(:rsa, OpenSSL::PKey::RSA, [1024, 2048, 3072, 4096]), + Technology.new(:dsa, OpenSSL::PKey::DSA, [1024, 2048, 3072]), + Technology.new(:ecdsa, OpenSSL::PKey::EC, [256, 384, 521]), + Technology.new(:ed25519, Net::SSH::Authentication::ED25519::PubKey, [256]) + ].freeze + + def self.technology(name) + Technologies.find { |tech| tech.name.to_s == name.to_s } + end + + def self.technology_for_key(key) + Technologies.find { |tech| key.is_a?(tech.key_class) } + end + + def self.supported_sizes(name) + technology(name)&.supported_sizes + end + + attr_reader :key_text, :key + + # Unqualified MD5 fingerprint for compatibility + delegate :fingerprint, to: :key, allow_nil: true + + def initialize(key_text) + @key_text = key_text + + @key = + begin + Net::SSH::KeyFactory.load_data_public_key(key_text) + rescue StandardError, NotImplementedError + end + end + + def valid? + key.present? + end + + def type + technology.name if valid? + end + + def bits + return unless valid? + + case type + when :rsa + key.n.num_bits + when :dsa + key.p.num_bits + when :ecdsa + key.group.order.num_bits + when :ed25519 + 256 + else + raise "Unsupported key type: #{type}" + end + end + + private + + def technology + @technology ||= + self.class.technology_for_key(key) || raise("Unsupported key type: #{key.class}") + end + end +end diff --git a/lib/gitlab/template/base_template.rb b/lib/gitlab/template/base_template.rb index 7ebec8e2cff..7393574ac13 100644 --- a/lib/gitlab/template/base_template.rb +++ b/lib/gitlab/template/base_template.rb @@ -18,6 +18,10 @@ module Gitlab { name: name, content: content } end + def <=>(other) + name <=> other.name + end + class << self def all(project = nil) if categories.any? @@ -58,7 +62,7 @@ module Gitlab directory = category_directory(category) files = finder(project).list_files_for(directory) - files.map { |f| new(f, project) } + files.map { |f| new(f, project) }.sort end def category_directory(category) diff --git a/lib/gitlab/utils.rb b/lib/gitlab/utils.rb index 9670c93759e..abb3d3a02c3 100644 --- a/lib/gitlab/utils.rb +++ b/lib/gitlab/utils.rb @@ -42,5 +42,9 @@ module Gitlab 'No' end end + + def random_string + Random.rand(Float::MAX.to_i).to_s(36) + end end end diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index a362a3a0bc6..e5ad9b5a40c 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -35,10 +35,7 @@ module Gitlab when 'git_receive_pack' Gitlab::GitalyClient.feature_enabled?(:post_receive_pack) when 'git_upload_pack' - Gitlab::GitalyClient.feature_enabled?( - :post_upload_pack, - status: Gitlab::GitalyClient::MigrationStatus::OPT_OUT - ) + true when 'info_refs' true else diff --git a/lib/system_check/app/git_user_default_ssh_config_check.rb b/lib/system_check/app/git_user_default_ssh_config_check.rb new file mode 100644 index 00000000000..7b486d78cf0 --- /dev/null +++ b/lib/system_check/app/git_user_default_ssh_config_check.rb @@ -0,0 +1,69 @@ +module SystemCheck + module App + class GitUserDefaultSSHConfigCheck < SystemCheck::BaseCheck + # These files are allowed in the .ssh directory. The `config` file is not + # whitelisted as it may change the SSH client's behaviour dramatically. + WHITELIST = %w[ + authorized_keys + authorized_keys2 + known_hosts + ].freeze + + set_name 'Git user has default SSH configuration?' + set_skip_reason 'skipped (git user is not present or configured)' + + def skip? + !home_dir || !File.directory?(home_dir) + end + + def check? + forbidden_files.empty? + end + + def show_error + backup_dir = "~/gitlab-check-backup-#{Time.now.to_i}" + + instructions = forbidden_files.map do |filename| + "sudo mv #{Shellwords.escape(filename)} #{backup_dir}" + end + + try_fixing_it("mkdir #{backup_dir}", *instructions) + for_more_information('doc/ssh/README.md in section "SSH on the GitLab server"') + fix_and_rerun + end + + private + + def git_user + Gitlab.config.gitlab.user + end + + def home_dir + return @home_dir if defined?(@home_dir) + + @home_dir = + begin + File.expand_path("~#{git_user}") + rescue ArgumentError + nil + end + end + + def ssh_dir + return nil unless home_dir + + File.join(home_dir, '.ssh') + end + + def forbidden_files + @forbidden_files ||= + begin + present = Dir[File.join(ssh_dir, '*')] + whitelisted = WHITELIST.map { |basename| File.join(ssh_dir, basename) } + + present - whitelisted + end + end + end + end +end diff --git a/lib/tasks/gettext.rake b/lib/tasks/gettext.rake index b48e4dce445..35ba729c156 100644 --- a/lib/tasks/gettext.rake +++ b/lib/tasks/gettext.rake @@ -19,4 +19,46 @@ namespace :gettext do Rake::Task['gettext:pack'].invoke Rake::Task['gettext:po_to_json'].invoke end + + desc 'Lint all po files in `locale/' + task lint: :environment do + require 'simple_po_parser' + + FastGettext.silence_errors + files = Dir.glob(Rails.root.join('locale/*/gitlab.po')) + + linters = files.map do |file| + locale = File.basename(File.dirname(file)) + + Gitlab::I18n::PoLinter.new(file, locale) + end + + pot_file = Rails.root.join('locale/gitlab.pot') + linters.unshift(Gitlab::I18n::PoLinter.new(pot_file)) + + failed_linters = linters.select { |linter| linter.errors.any? } + + if failed_linters.empty? + puts 'All PO files are valid.' + else + failed_linters.each do |linter| + report_errors_for_file(linter.po_path, linter.errors) + end + + raise "Not all PO-files are valid: #{failed_linters.map(&:po_path).to_sentence}" + end + end + + def report_errors_for_file(file, errors_for_file) + puts "Errors in `#{file}`:" + + errors_for_file.each do |message_id, errors| + puts " #{message_id}" + errors.each do |error| + spaces = ' ' * 4 + error = error.lines.join("#{spaces}") + puts "#{spaces}#{error}" + end + end + end end diff --git a/lib/tasks/gitlab/check.rake b/lib/tasks/gitlab/check.rake index 1bd36bbe20a..92a3f503fcb 100644 --- a/lib/tasks/gitlab/check.rake +++ b/lib/tasks/gitlab/check.rake @@ -33,6 +33,7 @@ namespace :gitlab do SystemCheck::App::RedisVersionCheck, SystemCheck::App::RubyVersionCheck, SystemCheck::App::GitVersionCheck, + SystemCheck::App::GitUserDefaultSSHConfigCheck, SystemCheck::App::ActiveUsersCheck ] diff --git a/locale/en/gitlab.po b/locale/en/gitlab.po index 0ac591d4927..84232be601e 100644 --- a/locale/en/gitlab.po +++ b/locale/en/gitlab.po @@ -82,6 +82,9 @@ msgstr "" msgid "Add new directory" msgstr "" +msgid "All" +msgstr "" + msgid "Archived project! Repository is read-only" msgstr "" @@ -222,6 +225,9 @@ msgstr "" msgid "CiStatus|running" msgstr "" +msgid "Comments" +msgstr "" + msgid "Commit" msgid_plural "Commits" msgstr[0] "" @@ -394,6 +400,24 @@ msgstr "" msgid "Edit Pipeline Schedule %{id}" msgstr "" +msgid "EventFilterBy|Filter by all" +msgstr "" + +msgid "EventFilterBy|Filter by comments" +msgstr "" + +msgid "EventFilterBy|Filter by issue events" +msgstr "" + +msgid "EventFilterBy|Filter by merge events" +msgstr "" + +msgid "EventFilterBy|Filter by push events" +msgstr "" + +msgid "EventFilterBy|Filter by team" +msgstr "" + msgid "Every day (at 4:00am)" msgstr "" @@ -489,6 +513,9 @@ msgstr "" msgid "Introducing Cycle Analytics" msgstr "" +msgid "Issue events" +msgstr "" + msgid "Jobs for last month" msgstr "" @@ -518,6 +545,12 @@ msgstr "" msgid "Last commit" msgstr "" +msgid "LastPushEvent|You pushed to" +msgstr "" + +msgid "LastPushEvent|at" +msgstr "" + msgid "Learn more in the" msgstr "" @@ -538,6 +571,9 @@ msgstr[1] "" msgid "Median" msgstr "" +msgid "Merge events" +msgstr "" + msgid "MissingSSHKeyWarningLink|add an SSH key" msgstr "" @@ -741,6 +777,9 @@ msgstr "" msgid "Pipeline|with stages" msgstr "" +msgid "Project" +msgstr "" + msgid "Project '%{project_name}' queued for deletion." msgstr "" @@ -774,6 +813,9 @@ msgstr "" msgid "Project home" msgstr "" +msgid "ProjectActivityRSS|Subscribe" +msgstr "" + msgid "ProjectFeature|Disabled" msgstr "" @@ -795,6 +837,9 @@ msgstr "" msgid "ProjectNetworkGraph|Graph" msgstr "" +msgid "Push events" +msgstr "" + msgid "Read more" msgstr "" @@ -925,6 +970,9 @@ msgstr "" msgid "Target Branch" msgstr "" +msgid "Team" +msgstr "" + 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 "" diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5a1db208d5a..97bc3d80642 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -3,13 +3,12 @@ # This file is distributed under the same license as the gitlab package. # FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. # -#, fuzzy msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-08-24 09:29+0200\n" -"PO-Revision-Date: 2017-08-24 09:29+0200\n" +"POT-Creation-Date: 2017-08-31 17:34+0530\n" +"PO-Revision-Date: 2017-08-31 17:34+0530\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -428,6 +427,9 @@ msgstr "" msgid "Every week (Sundays at 4:00am)" msgstr "" +msgid "Explore projects" +msgstr "" + msgid "Failed to change the owner" msgstr "" @@ -838,6 +840,27 @@ msgstr "" msgid "ProjectNetworkGraph|Graph" msgstr "" +msgid "ProjectsDropdown|Frequently visited" +msgstr "" + +msgid "ProjectsDropdown|Loading projects" +msgstr "" + +msgid "ProjectsDropdown|No projects matched your query" +msgstr "" + +msgid "ProjectsDropdown|Projects you visit often will appear here" +msgstr "" + +msgid "ProjectsDropdown|Search projects" +msgstr "" + +msgid "ProjectsDropdown|Something went wrong on our end." +msgstr "" + +msgid "ProjectsDropdown|This feature requires browser localStorage support" +msgstr "" + msgid "Push events" msgstr "" @@ -951,6 +974,9 @@ msgstr "" msgid "StarProject|Star" msgstr "" +msgid "Starred projects" +msgstr "" + msgid "Start a %{new_merge_request} with these changes" msgstr "" @@ -1272,6 +1298,9 @@ msgstr "" msgid "Your name" msgstr "" +msgid "Your projects" +msgstr "" + msgid "day" msgid_plural "days" msgstr[0] "" diff --git a/locale/ja/gitlab.po b/locale/ja/gitlab.po index 4037ff731a2..670ac2d9684 100644 --- a/locale/ja/gitlab.po +++ b/locale/ja/gitlab.po @@ -45,7 +45,7 @@ msgstr "" msgid "1 pipeline" msgid_plural "%d pipelines" -msgstr[0] "1 個のパイプライン" +msgstr[0] "%d 個のパイプライン" msgid "A collection of graphs regarding Continuous Integration" msgstr "CIについてのグラフ" diff --git a/locale/ko/gitlab.po b/locale/ko/gitlab.po index 125ca220c81..df850115222 100644 --- a/locale/ko/gitlab.po +++ b/locale/ko/gitlab.po @@ -45,7 +45,7 @@ msgstr "" msgid "1 pipeline" msgid_plural "%d pipelines" -msgstr[0] "1 파이프라인" +msgstr[0] "%d 파이프라인" msgid "A collection of graphs regarding Continuous Integration" msgstr "지속적인 통합에 관한 그래프 모음" diff --git a/locale/zh_CN/gitlab.po b/locale/zh_CN/gitlab.po index b25234da030..eb607acf1f4 100644 --- a/locale/zh_CN/gitlab.po +++ b/locale/zh_CN/gitlab.po @@ -45,7 +45,7 @@ msgstr "" msgid "1 pipeline" msgid_plural "%d pipelines" -msgstr[0] "1 条流水线" +msgstr[0] "%d 条流水线" msgid "A collection of graphs regarding Continuous Integration" msgstr "持续集成数据图" diff --git a/locale/zh_HK/gitlab.po b/locale/zh_HK/gitlab.po index 8a3a69a0ac0..74c7b464091 100644 --- a/locale/zh_HK/gitlab.po +++ b/locale/zh_HK/gitlab.po @@ -45,7 +45,7 @@ msgstr "" msgid "1 pipeline" msgid_plural "%d pipelines" -msgstr[0] "1 條流水線" +msgstr[0] "%d 條流水線" msgid "A collection of graphs regarding Continuous Integration" msgstr "相關持續集成的圖像集合" diff --git a/locale/zh_TW/gitlab.po b/locale/zh_TW/gitlab.po index 91c1cc6bf66..1fc6b79187f 100644 --- a/locale/zh_TW/gitlab.po +++ b/locale/zh_TW/gitlab.po @@ -45,7 +45,7 @@ msgstr "" msgid "1 pipeline" msgid_plural "%d pipelines" -msgstr[0] "1 條流水線" +msgstr[0] "%d 條流水線" msgid "A collection of graphs regarding Continuous Integration" msgstr "持續整合 (CI) 相關的圖表" @@ -1208,16 +1208,16 @@ msgid "Withdraw Access Request" msgstr "取消權限申請" msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?" -msgstr "即將要刪除 %{group_name}。被刪除的群組完全無法救回來喔!真的「100%確定」要這麼做嗎?" +msgstr "即將要刪除 %{group_name}。被刪除的群組無法復原!真的「確定」要這麼做嗎?" msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?" -msgstr "即將要刪除 %{project_name_with_namespace}。被刪除的專案完全無法救回來喔!真的「100%確定」要這麼做嗎?" +msgstr "即將要刪除 %{project_name_with_namespace}。被刪除的專案無法復原!真的「確定」要這麼做嗎?" msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?" -msgstr "將要刪除本分支專案與主幹的所有關聯 (fork relationship) 。 %{forked_from_project} 真的「100%確定」要這麼做嗎?" +msgstr "將要刪除本分支專案與主幹 %{forked_from_project} 的所有關聯。 真的「確定」要這麼做嗎?" msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?" -msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「100%確定」要這麼做嗎?" +msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「確定」要這麼做嗎?" msgid "You can only add files when you are on a branch" msgstr "只能在分支 (branch) 上建立檔案" diff --git a/package.json b/package.json index 99704c07849..feae6ca9748 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "vue-loader": "^11.3.4", "vue-resource": "^1.3.4", "vue-template-compiler": "^2.2.6", + "vuex": "^2.3.1", "webpack": "^3.5.5", "webpack-bundle-analyzer": "^2.8.2", "webpack-stats-plugin": "^0.1.5" diff --git a/public/404.html b/public/404.html index 4db72be6f8c..08f328da542 100644 --- a/public/404.html +++ b/public/404.html @@ -72,8 +72,9 @@ 404 </h1> <div class="container"> - <h3>The page you're looking for could not be found.</h3> + <h3>The page could not be found or you don't have permission to view it.</h3> <hr /> + <p>The resource that you are attempting to access does not exist or you don't have the necessary permissions to view it.</p> <p>Make sure the address is correct and that the page hasn't moved.</p> <p>Please contact your GitLab administrator if you think this is a mistake.</p> <a href="javascript:history.back()" class="js-go-back go-back">Go back</a> diff --git a/scripts/static-analysis b/scripts/static-analysis index 52529e64b30..295b6f132c1 100755 --- a/scripts/static-analysis +++ b/scripts/static-analysis @@ -12,7 +12,8 @@ tasks = [ %w[bundle exec license_finder], %w[yarn run eslint], %w[bundle exec rubocop --require rubocop-rspec], - %w[scripts/lint-conflicts.sh] + %w[scripts/lint-conflicts.sh], + %w[bundle exec rake gettext:lint] ] failed_tasks = tasks.reduce({}) do |failures, task| diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 331903a5543..59a6cfbf4f5 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -8,34 +8,43 @@ describe ApplicationController do it 'redirects if the user is over their password expiry' do user.password_expires_at = Time.new(2002) + expect(user.ldap_user?).to be_falsey allow(controller).to receive(:current_user).and_return(user) expect(controller).to receive(:redirect_to) expect(controller).to receive(:new_profile_password_path) + controller.send(:check_password_expiration) end it 'does not redirect if the user is under their password expiry' do user.password_expires_at = Time.now + 20010101 + expect(user.ldap_user?).to be_falsey allow(controller).to receive(:current_user).and_return(user) expect(controller).not_to receive(:redirect_to) + controller.send(:check_password_expiration) end it 'does not redirect if the user is over their password expiry but they are an ldap user' do user.password_expires_at = Time.new(2002) + allow(user).to receive(:ldap_user?).and_return(true) allow(controller).to receive(:current_user).and_return(user) expect(controller).not_to receive(:redirect_to) + controller.send(:check_password_expiration) end - it 'does not redirect if the user is over their password expiry but sign-in is disabled' do + it 'redirects if the user is over their password expiry and sign-in is disabled' do stub_application_setting(password_authentication_enabled: false) user.password_expires_at = Time.new(2002) + + expect(user.ldap_user?).to be_falsey allow(controller).to receive(:current_user).and_return(user) - expect(controller).not_to receive(:redirect_to) + expect(controller).to receive(:redirect_to) + expect(controller).to receive(:new_profile_password_path) controller.send(:check_password_expiration) end diff --git a/spec/controllers/autocomplete_controller_spec.rb b/spec/controllers/autocomplete_controller_spec.rb index 572b567cddf..be27bbb4283 100644 --- a/spec/controllers/autocomplete_controller_spec.rb +++ b/spec/controllers/autocomplete_controller_spec.rb @@ -241,13 +241,10 @@ describe AutocompleteController do it 'returns projects' do expect(json_response).to be_kind_of(Array) - expect(json_response.size).to eq(2) - - expect(json_response.first['id']).to eq(0) - expect(json_response.first['name_with_namespace']).to eq 'No project' + expect(json_response.size).to eq(1) - expect(json_response.last['id']).to eq authorized_project.id - expect(json_response.last['name_with_namespace']).to eq authorized_project.name_with_namespace + expect(json_response.first['id']).to eq authorized_project.id + expect(json_response.first['name_with_namespace']).to eq authorized_project.name_with_namespace end end end @@ -265,10 +262,10 @@ describe AutocompleteController do it 'returns projects' do expect(json_response).to be_kind_of(Array) - expect(json_response.size).to eq(2) + expect(json_response.size).to eq(1) - expect(json_response.last['id']).to eq authorized_search_project.id - expect(json_response.last['name_with_namespace']).to eq authorized_search_project.name_with_namespace + expect(json_response.first['id']).to eq authorized_search_project.id + expect(json_response.first['name_with_namespace']).to eq authorized_search_project.name_with_namespace end end end @@ -292,7 +289,7 @@ describe AutocompleteController do it 'returns projects' do expect(json_response).to be_kind_of(Array) - expect(json_response.size).to eq 3 # Of a total of 4 + expect(json_response.size).to eq 2 # Of a total of 3 end end end @@ -312,9 +309,9 @@ describe AutocompleteController do get(:projects, project_id: project.id, offset_id: authorized_project.id) end - it 'returns "No project"' do - expect(json_response.detect { |item| item['id'] == 0 }).to be_nil # 'No project' is not there - expect(json_response.detect { |item| item['id'] == authorized_project.id }).to be_nil # Offset project is not there either + it 'returns projects' do + expect(json_response).to be_kind_of(Array) + expect(json_response.size).to eq 2 # Of a total of 3 end end end @@ -331,10 +328,9 @@ describe AutocompleteController do get(:projects, project_id: project.id) end - it 'returns a single "No project"' do + it 'returns no projects' do expect(json_response).to be_kind_of(Array) - expect(json_response.size).to eq(1) # 'No project' - expect(json_response.first['id']).to eq 0 + expect(json_response.size).to eq(0) end end end diff --git a/spec/controllers/concerns/issuable_collections_spec.rb b/spec/controllers/concerns/issuable_collections_spec.rb new file mode 100644 index 00000000000..c9687af4dd2 --- /dev/null +++ b/spec/controllers/concerns/issuable_collections_spec.rb @@ -0,0 +1,82 @@ +require 'spec_helper' + +describe IssuableCollections do + let(:user) { create(:user) } + + let(:controller) do + klass = Class.new do + def self.helper_method(name); end + + include IssuableCollections + end + + controller = klass.new + + allow(controller).to receive(:params).and_return(state: 'opened') + + controller + end + + describe '#redirect_out_of_range' do + before do + allow(controller).to receive(:url_for) + end + + it 'returns true and redirects if the offset is out of range' do + relation = double(:relation, current_page: 10) + + expect(controller).to receive(:redirect_to) + expect(controller.send(:redirect_out_of_range, relation, 2)).to eq(true) + end + + it 'returns false if the offset is not out of range' do + relation = double(:relation, current_page: 1) + + expect(controller).not_to receive(:redirect_to) + expect(controller.send(:redirect_out_of_range, relation, 2)).to eq(false) + end + end + + describe '#issues_page_count' do + it 'returns the number of issue pages' do + project = create(:project, :public) + + create(:issue, project: project) + + finder = IssuesFinder.new(user) + issues = finder.execute + + allow(controller).to receive(:issues_finder) + .and_return(finder) + + expect(controller.send(:issues_page_count, issues)).to eq(1) + end + end + + describe '#merge_requests_page_count' do + it 'returns the number of merge request pages' do + project = create(:project, :public) + + create(:merge_request, source_project: project, target_project: project) + + finder = MergeRequestsFinder.new(user) + merge_requests = finder.execute + + allow(controller).to receive(:merge_requests_finder) + .and_return(finder) + + pages = controller.send(:merge_requests_page_count, merge_requests) + + expect(pages).to eq(1) + end + end + + describe '#page_count_for_relation' do + it 'returns the number of pages' do + relation = double(:relation, limit_value: 20) + pages = controller.send(:page_count_for_relation, relation, 28) + + expect(pages).to eq(2) + end + end +end diff --git a/spec/controllers/passwords_controller_spec.rb b/spec/controllers/passwords_controller_spec.rb index 2955d01fad0..cdaa88bbf5d 100644 --- a/spec/controllers/passwords_controller_spec.rb +++ b/spec/controllers/passwords_controller_spec.rb @@ -1,18 +1,18 @@ require 'spec_helper' describe PasswordsController do - describe '#check_password_authentication_available' do + describe '#prevent_ldap_reset' do before do @request.env["devise.mapping"] = Devise.mappings[:user] end context 'when password authentication is disabled' do - it 'prevents a password reset' do + it 'allows password reset' do stub_application_setting(password_authentication_enabled: false) post :create - expect(flash[:alert]).to eq 'Password authentication is unavailable.' + expect(response).to have_http_status(302) end end @@ -22,7 +22,7 @@ describe PasswordsController do it 'prevents a password reset' do post :create, user: { email: user.email } - expect(flash[:alert]).to eq 'Password authentication is unavailable.' + expect(flash[:alert]).to eq('Cannot reset password for LDAP user.') end end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index da8f9e8376e..5d9403c23ac 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -233,144 +233,119 @@ describe Projects::IssuesController do end end - context 'when moving issue to another private project' do - let(:another_project) { create(:project, :private) } - - context 'when user has access to move issue' do - before do - another_project.team << [user, :reporter] - end - - it 'moves issue to another project' do - move_issue + context 'Akismet is enabled' do + let(:project) { create(:project_empty_repo, :public) } - expect(response).to have_http_status :found - expect(another_project.issues).not_to be_empty - end + before do + stub_application_setting(recaptcha_enabled: true) + allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true) end - context 'when user does not have access to move issue' do - it 'responds with 404' do - move_issue + context 'when an issue is not identified as spam' do + before do + allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false) + allow_any_instance_of(AkismetService).to receive(:spam?).and_return(false) + end - expect(response).to have_http_status :not_found + it 'normally updates the issue' do + expect { update_issue(title: 'Foo') }.to change { issue.reload.title }.to('Foo') end end - context 'Akismet is enabled' do - let(:project) { create(:project_empty_repo, :public) } - + context 'when an issue is identified as spam' do before do - stub_application_setting(recaptcha_enabled: true) - allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true) + allow_any_instance_of(AkismetService).to receive(:spam?).and_return(true) end - context 'when an issue is not identified as spam' do - before do - allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false) - allow_any_instance_of(AkismetService).to receive(:spam?).and_return(false) + context 'when captcha is not verified' do + def update_spam_issue + update_issue(title: 'Spam Title', description: 'Spam lives here') end - it 'normally updates the issue' do - expect { update_issue(title: 'Foo') }.to change { issue.reload.title }.to('Foo') + before do + allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false) end - end - context 'when an issue is identified as spam' do - before do - allow_any_instance_of(AkismetService).to receive(:spam?).and_return(true) + it 'rejects an issue recognized as a spam' do + expect(Gitlab::Recaptcha).to receive(:load_configurations!).and_return(true) + expect { update_spam_issue }.not_to change { issue.reload.title } end - context 'when captcha is not verified' do - def update_spam_issue - update_issue(title: 'Spam Title', description: 'Spam lives here') - end + it 'rejects an issue recognized as a spam when recaptcha disabled' do + stub_application_setting(recaptcha_enabled: false) - before do - allow_any_instance_of(described_class).to receive(:verify_recaptcha).and_return(false) - end + expect { update_spam_issue }.not_to change { issue.reload.title } + end - it 'rejects an issue recognized as a spam' do - expect(Gitlab::Recaptcha).to receive(:load_configurations!).and_return(true) - expect { update_spam_issue }.not_to change { issue.reload.title } - end + it 'creates a spam log' do + update_spam_issue - it 'rejects an issue recognized as a spam when recaptcha disabled' do - stub_application_setting(recaptcha_enabled: false) + spam_logs = SpamLog.all - expect { update_spam_issue }.not_to change { issue.reload.title } - end + expect(spam_logs.count).to eq(1) + expect(spam_logs.first.title).to eq('Spam Title') + expect(spam_logs.first.recaptcha_verified).to be_falsey + end - it 'creates a spam log' do + context 'as HTML' do + it 'renders verify template' do update_spam_issue - spam_logs = SpamLog.all - - expect(spam_logs.count).to eq(1) - expect(spam_logs.first.title).to eq('Spam Title') - expect(spam_logs.first.recaptcha_verified).to be_falsey + expect(response).to render_template(:verify) end + end - context 'as HTML' do - it 'renders verify template' do - update_spam_issue - - expect(response).to render_template(:verify) - end + context 'as JSON' do + before do + update_issue({ title: 'Spam Title', description: 'Spam lives here' }, format: :json) end - context 'as JSON' do - before do - update_issue({ title: 'Spam Title', description: 'Spam lives here' }, format: :json) - end - - it 'renders json errors' do - expect(json_response) - .to eql("errors" => ["Your issue has been recognized as spam. Please, change the content or solve the reCAPTCHA to proceed."]) - end + it 'renders json errors' do + expect(json_response) + .to eql("errors" => ["Your issue has been recognized as spam. Please, change the content or solve the reCAPTCHA to proceed."]) + end - it 'returns 422 status' do - expect(response).to have_http_status(422) - end + it 'returns 422 status' do + expect(response).to have_http_status(422) end end + end - context 'when captcha is verified' do - let(:spammy_title) { 'Whatever' } - let!(:spam_logs) { create_list(:spam_log, 2, user: user, title: spammy_title) } + context 'when captcha is verified' do + let(:spammy_title) { 'Whatever' } + let!(:spam_logs) { create_list(:spam_log, 2, user: user, title: spammy_title) } - def update_verified_issue - update_issue({ title: spammy_title }, - { spam_log_id: spam_logs.last.id, - recaptcha_verification: true }) - end + def update_verified_issue + update_issue({ title: spammy_title }, + { spam_log_id: spam_logs.last.id, + recaptcha_verification: true }) + end - before do - allow_any_instance_of(described_class).to receive(:verify_recaptcha) - .and_return(true) - end + before do + allow_any_instance_of(described_class).to receive(:verify_recaptcha) + .and_return(true) + end - it 'redirect to issue page' do - update_verified_issue + it 'redirect to issue page' do + update_verified_issue - expect(response) - .to redirect_to(project_issue_path(project, issue)) - end + expect(response) + .to redirect_to(project_issue_path(project, issue)) + end - it 'accepts an issue after recaptcha is verified' do - expect { update_verified_issue }.to change { issue.reload.title }.to(spammy_title) - end + it 'accepts an issue after recaptcha is verified' do + expect { update_verified_issue }.to change { issue.reload.title }.to(spammy_title) + end - it 'marks spam log as recaptcha_verified' do - expect { update_verified_issue }.to change { SpamLog.last.recaptcha_verified }.from(false).to(true) - end + it 'marks spam log as recaptcha_verified' do + expect { update_verified_issue }.to change { SpamLog.last.recaptcha_verified }.from(false).to(true) + end - it 'does not mark spam log as recaptcha_verified when it does not belong to current_user' do - spam_log = create(:spam_log) + it 'does not mark spam log as recaptcha_verified when it does not belong to current_user' do + spam_log = create(:spam_log) - expect { update_issue(spam_log_id: spam_log.id, recaptcha_verification: true) } - .not_to change { SpamLog.last.recaptcha_verified } - end + expect { update_issue(spam_log_id: spam_log.id, recaptcha_verification: true) } + .not_to change { SpamLog.last.recaptcha_verified } end end end @@ -385,13 +360,45 @@ describe Projects::IssuesController do put :update, params end + end + end + + describe 'POST #move' do + before do + sign_in(user) + project.add_developer(user) + end + + context 'when moving issue to another private project' do + let(:another_project) { create(:project, :private) } + + context 'when user has access to move issue' do + before do + another_project.add_reporter(user) + end + + it 'moves issue to another project' do + move_issue + + expect(response).to have_http_status :ok + expect(another_project.issues).not_to be_empty + end + end + + context 'when user does not have access to move issue' do + it 'responds with 404' do + move_issue + + expect(response).to have_http_status :not_found + end + end def move_issue - put :update, + post :move, + format: :json, namespace_id: project.namespace.to_param, project_id: project, id: issue.iid, - issue: { title: 'New title' }, move_to_project_id: another_project.id end end @@ -879,4 +886,19 @@ describe Projects::IssuesController do format: :json end end + + describe 'GET #discussions' do + let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) } + + before do + project.add_developer(user) + sign_in(user) + end + + it 'returns discussion json' do + get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid + + expect(JSON.parse(response.body).first.keys).to match_array(%w[id reply_id expanded notes individual_note]) + end + end end diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index f280c55059c..6ffe41b8608 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -46,10 +46,13 @@ describe Projects::NotesController do end context 'for a discussion note' do - let!(:note) { create(:discussion_note_on_issue, noteable: issue, project: project) } + let(:project) { create(:project, :repository) } + let!(:note) { create(:discussion_note_on_merge_request, project: project) } + + let(:params) { request_params.merge(target_type: 'merge_request', target_id: note.noteable_id) } it 'responds with the expected attributes' do - get :index, request_params + get :index, params expect(note_json[:id]).to eq(note.id) expect(note_json[:discussion_html]).not_to be_nil @@ -104,10 +107,12 @@ describe Projects::NotesController do end context 'for a regular note' do - let!(:note) { create(:note, noteable: issue, project: project) } + let!(:note) { create(:note_on_merge_request, project: project) } + + let(:params) { request_params.merge(target_type: 'merge_request', target_id: note.noteable_id) } it 'responds with the expected attributes' do - get :index, request_params + get :index, params expect(note_json[:id]).to eq(note.id) expect(note_json[:html]).not_to be_nil @@ -125,7 +130,9 @@ describe Projects::NotesController do note: { note: 'some note', noteable_id: merge_request.id, noteable_type: 'MergeRequest' }, namespace_id: project.namespace, project_id: project, - merge_request_diff_head_sha: 'sha' + merge_request_diff_head_sha: 'sha', + target_type: 'merge_request', + target_id: merge_request.id } end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 5bba1dec7db..c2b59239af9 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -12,6 +12,7 @@ FactoryGirl.define do started_at 'Di 29. Okt 09:51:28 CET 2013' finished_at 'Di 29. Okt 09:53:28 CET 2013' commands 'ls -a' + protected false options do { @@ -106,7 +107,7 @@ FactoryGirl.define do end trait :triggered do - trigger_request factory: :ci_trigger_request_with_variables + trigger_request factory: :ci_trigger_request end after(:build) do |build, evaluator| @@ -226,5 +227,9 @@ FactoryGirl.define do status 'created' self.when 'manual' end + + trait :protected do + protected true + end end end diff --git a/spec/factories/ci/pipeline_variable_variables.rb b/spec/factories/ci/pipeline_variables.rb index 7c1a7faec08..7c1a7faec08 100644 --- a/spec/factories/ci/pipeline_variable_variables.rb +++ b/spec/factories/ci/pipeline_variables.rb diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index e83a0e599a8..e5ea6b41ea3 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -4,6 +4,7 @@ FactoryGirl.define do ref 'master' sha '97de212e80737a608d939f648d959671fb0a0142' status 'pending' + protected false project @@ -59,6 +60,10 @@ FactoryGirl.define do trait :failed do status :failed end + + trait :protected do + protected true + end end end end diff --git a/spec/factories/ci/runners.rb b/spec/factories/ci/runners.rb index 05abf60d5ce..88bb755d068 100644 --- a/spec/factories/ci/runners.rb +++ b/spec/factories/ci/runners.rb @@ -5,6 +5,7 @@ FactoryGirl.define do platform "darwin" is_shared false active true + access_level :not_protected trait :online do contacted_at Time.now @@ -21,5 +22,9 @@ FactoryGirl.define do trait :inactive do active false end + + trait :ref_protected do + access_level :ref_protected + end end end diff --git a/spec/factories/ci/trigger_requests.rb b/spec/factories/ci/trigger_requests.rb index 10e0ab4fd3c..40b8848920e 100644 --- a/spec/factories/ci/trigger_requests.rb +++ b/spec/factories/ci/trigger_requests.rb @@ -1,14 +1,5 @@ FactoryGirl.define do factory :ci_trigger_request, class: Ci::TriggerRequest do trigger factory: :ci_trigger - - factory :ci_trigger_request_with_variables do - variables do - { - TRIGGER_KEY_1: 'TRIGGER_VALUE_1', - TRIGGER_KEY_2: 'TRIGGER_VALUE_2' - } - end - end end end diff --git a/spec/factories/gpg_signature.rb b/spec/factories/gpg_signature.rb index a5aeffbe12d..c0beecf0bea 100644 --- a/spec/factories/gpg_signature.rb +++ b/spec/factories/gpg_signature.rb @@ -6,6 +6,6 @@ FactoryGirl.define do project gpg_key gpg_key_primary_keyid { gpg_key.primary_keyid } - valid_signature true + verification_status :verified end end diff --git a/spec/factories/keys.rb b/spec/factories/keys.rb index a13b6e3596e..3f7c794b14a 100644 --- a/spec/factories/keys.rb +++ b/spec/factories/keys.rb @@ -18,5 +18,54 @@ FactoryGirl.define do factory :write_access_key, class: 'DeployKey' do can_push true end + + factory :rsa_key_2048 do + key do + 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFf6RYK3qu/RKF/3ndJmL5xgMLp3O9' \ + '6x8lTay+QGZ0+9FnnAXMdUqBq/ZU6d/gyMB4IaW3nHzM1w049++yAB6UPCzMB8Uo27K5' \ + '/jyZCtj7Vm9PFNjF/8am1kp46c/SeYicQgQaSBdzIW3UDEa1Ef68qroOlvpi9PYZ/tA7' \ + 'M0YP0K5PXX+E36zaIRnJVMPT3f2k+GnrxtjafZrwFdpOP/Fol5BQLBgcsyiU+LM1SuaC' \ + 'rzd8c9vyaTA1CxrkxaZh+buAi0PmdDtaDrHd42gqZkXCKavyvgM5o2CkQ5LJHCgzpXy0' \ + '5qNFzmThBSkb+XtoxbyagBiGbVZtSVow6Xa7qewz= dummy@gitlab.com' + end + + factory :rsa_deploy_key_2048, class: 'DeployKey' + end + + factory :dsa_key_2048 do + key do + 'ssh-dss AAAAB3NzaC1kc3MAAAEBAO/3/NPLA/zSFkMOCaTtGo+uos1flfQ5f038Uk+G' \ + 'Y9AeLGzX+Srhw59GdVXmOQLYBrOt5HdGwqYcmLnE2VurUGmhtfeO5H+3p5pGJbkS0Gxp' \ + 'YH1HRO9lWsncF3Hh1w4lYsDjkclDiSTdfTuN8F4Kb3DXNnVSCieeonp+B25F/CXagyTQ' \ + '/pvNmHFeYgGCVdnBtFdi+xfxaZ8NKdPrGggzokbKHElDZQ4Xo5EpdcyLajgM7nB2r2Rz' \ + 'OrmeaevKi5lV68ehRa9Yyrb7vxvwiwBwOgqR/mnN7Gnaq1jUdmJY+ct04Qwx37f5jvhv' \ + '5gA4U40SGMoiHM8RFIN7Ksz0jsyX73MAAAAVALRWOfjfzHpK7KLz4iqDvvTUAevJAAAB' \ + 'AEa9NZ+6y9iQ5erGsdfLTXFrhSefTG0NhghoO/5IFkSGfd8V7kzTvCHaFrcfpEA5kP8t' \ + 'poeOG0TASB6tgGOxm1Bq4Wncry5RORBPJlAVpDGRcvZ931ddH7IgltEInS6za2uH6F/1' \ + 'M1QfKePSLr6xJ1ZLYfP0Og5KTp1x6yMQvfwV0a+XdA+EPgaJWLWp/pWwKWa0oLUgjsIH' \ + 'MYzuOGh5c708uZrmkzqvgtW2NgXhcIroRgynT3IfI2lP2rqqb3uuuE/qH5UCUFO+Dc3H' \ + 'nAFNeQDT/M25AERdPYBAY5a+iPjIgO+jT7BfmfByT+AZTqZySrCyc7nNZL3YgGLK0l6A' \ + '1GgAAAEBAN9FpFOdIXE+YEZhKl1vPmbcn+b1y5zOl6N4x1B7Q8pD/pLMziWROIS8uLzb' \ + 'aZ0sMIWezHIkxuo1iROMeT+jtCubn7ragaN6AX7nMpxYUH9+mYZZs/fyElt6wCviVhTI' \ + 'zM+u7VdQsnZttOOlQfogHdL+SpeAft0DsfJjlcgQnsLlHQKv6aPqCPYUST2nE7RyW/Ex' \ + 'PrMxLtOWt0/j8RYHbwwqvyeZqBz3ESBgrS9c5tBdBfauwYUV/E7gPLOU3OZFw9ue7o+z' \ + 'wzoTZqW6Xouy5wtWvSLQSLT5XwOslmQz8QMBxD0AQyDfEFGsBCWzmbTgKv9uqrBjubsS' \ + 'Taja+Cf9kMo== dummy@gitlab.com' + end + end + + factory :ecdsa_key_256 do + key do + 'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYA' \ + 'AABBBJZmkzTgY0fiCQ+DVReyH/fFwTFz0XoR3RUO0u+199H19KFw7mNPxRSMOVS7tEtO' \ + 'Nj3Q7FcZXfqthHvgAzDiHsc= dummy@gitlab.com' + end + end + + factory :ed25519_key_256 do + key do + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIETnVTgzqC1gatgSlC4zH6aYt2CAQzgJOhDRvf59ohL6 dummy@gitlab.com' + end + end end end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 9ebda0ba03b..7493b0a8b35 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -101,8 +101,6 @@ FactoryGirl.define do # Test repository - https://gitlab.com/gitlab-org/gitlab-test trait :repository do - path { 'gitlabhq' } - test_repo transient do diff --git a/spec/features/admin/admin_active_tab_spec.rb b/spec/features/admin/admin_active_tab_spec.rb index 07430ecd6e0..5ff791fc36a 100644 --- a/spec/features/admin/admin_active_tab_spec.rb +++ b/spec/features/admin/admin_active_tab_spec.rb @@ -7,15 +7,15 @@ RSpec.describe 'admin active tab' do shared_examples 'page has active tab' do |title| it "activates #{title} tab" do - expect(page).to have_selector('.layout-nav .nav-links > li.active', count: 1) - expect(page.find('.layout-nav li.active')).to have_content(title) + expect(page).to have_selector('.nav-sidebar .sidebar-top-level-items > li.active', count: 1) + expect(page.find('.nav-sidebar .sidebar-top-level-items > li.active')).to have_content(title) end end shared_examples 'page has active sub tab' do |title| it "activates #{title} sub tab" do - expect(page).to have_selector('.sub-nav li.active', count: 1) - expect(page.find('.sub-nav li.active')).to have_content(title) + expect(page).to have_selector('.sidebar-sub-level-items li.active', count: 1) + expect(page.find('.sidebar-sub-level-items li.active')).to have_content(title) end end diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb index 30fcb334b60..91f08dbad5d 100644 --- a/spec/features/admin/admin_hooks_spec.rb +++ b/spec/features/admin/admin_hooks_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Admin::Hooks' do +describe 'Admin::Hooks', :js do before do @project = create(:project) sign_in(create(:admin)) @@ -12,7 +12,7 @@ describe 'Admin::Hooks' do it 'is ok' do visit admin_root_path - page.within '.layout-nav' do + page.within '.nav-sidebar' do click_on 'Hooks' end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index dbb0ae9c86e..563818e8761 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -79,6 +79,22 @@ feature 'Admin updates settings' do end end + scenario 'Change Keys settings' do + select 'Are forbidden', from: 'RSA SSH keys' + select 'Are allowed', from: 'DSA SSH keys' + select 'Must be at least 384 bits', from: 'ECDSA SSH keys' + select 'Are forbidden', from: 'ED25519 SSH keys' + click_on 'Save' + + forbidden = ApplicationSetting::FORBIDDEN_KEY_VALUE.to_s + + expect(page).to have_content 'Application settings saved successfully' + expect(find_field('RSA SSH keys').value).to eq(forbidden) + expect(find_field('DSA SSH keys').value).to eq('0') + expect(find_field('ECDSA SSH keys').value).to eq('384') + expect(find_field('ED25519 SSH keys').value).to eq(forbidden) + end + def check_all_events page.check('Active') page.check('Push') diff --git a/spec/features/boards/add_issues_modal_spec.rb b/spec/features/boards/add_issues_modal_spec.rb index a6ad5981f8f..c480b5b7e34 100644 --- a/spec/features/boards/add_issues_modal_spec.rb +++ b/spec/features/boards/add_issues_modal_spec.rb @@ -8,8 +8,8 @@ describe 'Issue Boards add issue modal', :js do let!(:label) { create(:label, project: project) } let!(:list1) { create(:list, board: board, label: planning, position: 0) } let!(:list2) { create(:list, board: board, label: label, position: 1) } - let!(:issue) { create(:issue, project: project) } - let!(:issue2) { create(:issue, project: project) } + let!(:issue) { create(:issue, project: project, title: 'abc', description: 'def') } + let!(:issue2) { create(:issue, project: project, title: 'hij', description: 'klm') } before do project.team << [user, :master] diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index ce458431c55..e010b5f3444 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -13,6 +13,8 @@ describe 'Issue Boards', js: true do project.team << [user, :master] project.team << [user2, :master] + allow_any_instance_of(ApplicationHelper).to receive(:collapsed_sidebar?).and_return(true) + sign_in(user) end @@ -71,15 +73,15 @@ describe 'Issue Boards', js: true do let!(:list2) { create(:list, board: board, label: development, position: 1) } let!(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning], relative_position: 9) } - let!(:issue1) { create(:labeled_issue, project: project, assignees: [user], labels: [planning], relative_position: 8) } - let!(:issue2) { create(:labeled_issue, project: project, author: user2, labels: [planning], relative_position: 7) } - let!(:issue3) { create(:labeled_issue, project: project, labels: [planning], relative_position: 6) } - let!(:issue4) { create(:labeled_issue, project: project, labels: [planning], relative_position: 5) } - let!(:issue5) { create(:labeled_issue, project: project, labels: [planning], milestone: milestone, relative_position: 4) } - let!(:issue6) { create(:labeled_issue, project: project, labels: [planning, development], relative_position: 3) } - let!(:issue7) { create(:labeled_issue, project: project, labels: [development], relative_position: 2) } - let!(:issue8) { create(:closed_issue, project: project) } - let!(:issue9) { create(:labeled_issue, project: project, labels: [planning, testing, bug, accepting], relative_position: 1) } + let!(:issue1) { create(:labeled_issue, project: project, title: 'aaa', description: '111', assignees: [user], labels: [planning], relative_position: 8) } + let!(:issue2) { create(:labeled_issue, project: project, title: 'bbb', description: '222', author: user2, labels: [planning], relative_position: 7) } + let!(:issue3) { create(:labeled_issue, project: project, title: 'ccc', description: '333', labels: [planning], relative_position: 6) } + let!(:issue4) { create(:labeled_issue, project: project, title: 'ddd', description: '444', labels: [planning], relative_position: 5) } + let!(:issue5) { create(:labeled_issue, project: project, title: 'eee', description: '555', labels: [planning], milestone: milestone, relative_position: 4) } + let!(:issue6) { create(:labeled_issue, project: project, title: 'fff', description: '666', labels: [planning, development], relative_position: 3) } + let!(:issue7) { create(:labeled_issue, project: project, title: 'ggg', description: '777', labels: [development], relative_position: 2) } + let!(:issue8) { create(:closed_issue, project: project, title: 'hhh', description: '888') } + let!(:issue9) { create(:labeled_issue, project: project, title: 'iii', description: '999', labels: [planning, testing, bug, accepting], relative_position: 1) } before do visit project_board_path(project, board) @@ -145,6 +147,8 @@ describe 'Issue Boards', js: true do click_button 'Add list' wait_for_requests + find('.dropdown-menu-close').click + page.within(find('.board:nth-child(2)')) do find('.board-delete').click end diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 0c9fcc60d30..479fb713297 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -203,105 +203,4 @@ describe 'Commits' do end end end - - describe 'GPG signed commits', :js do - it 'changes from unverified to verified when the user changes his email to match the gpg key' do - user = create :user, email: 'unrelated.user@example.org' - project.team << [user, :master] - - Sidekiq::Testing.inline! do - create :gpg_key, key: GpgHelpers::User1.public_key, user: user - end - - sign_in(user) - - visit project_commits_path(project, :'signed-commits') - - within '#commits-list' do - expect(page).to have_content 'Unverified' - expect(page).not_to have_content 'Verified' - end - - # user changes his email which makes the gpg key verified - Sidekiq::Testing.inline! do - user.skip_reconfirmation! - user.update_attributes!(email: GpgHelpers::User1.emails.first) - end - - visit project_commits_path(project, :'signed-commits') - - within '#commits-list' do - expect(page).to have_content 'Unverified' - expect(page).to have_content 'Verified' - end - end - - it 'changes from unverified to verified when the user adds the missing gpg key' do - user = create :user, email: GpgHelpers::User1.emails.first - project.team << [user, :master] - - sign_in(user) - - visit project_commits_path(project, :'signed-commits') - - within '#commits-list' do - expect(page).to have_content 'Unverified' - expect(page).not_to have_content 'Verified' - end - - # user adds the gpg key which makes the signature valid - Sidekiq::Testing.inline! do - create :gpg_key, key: GpgHelpers::User1.public_key, user: user - end - - visit project_commits_path(project, :'signed-commits') - - within '#commits-list' do - expect(page).to have_content 'Unverified' - expect(page).to have_content 'Verified' - end - end - - it 'shows popover badges' do - gpg_user = create :user, email: GpgHelpers::User1.emails.first, username: 'nannie.bernhard', name: 'Nannie Bernhard' - Sidekiq::Testing.inline! do - create :gpg_key, key: GpgHelpers::User1.public_key, user: gpg_user - end - - user = create :user - project.team << [user, :master] - - sign_in(user) - visit project_commits_path(project, :'signed-commits') - - # unverified signature - click_on 'Unverified', match: :first - within '.popover' do - expect(page).to have_content 'This commit was signed with an unverified signature.' - expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}" - end - - # verified and the gpg user has a gitlab profile - click_on 'Verified', match: :first - within '.popover' do - expect(page).to have_content 'This commit was signed with a verified signature.' - expect(page).to have_content 'Nannie Bernhard' - expect(page).to have_content '@nannie.bernhard' - expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}" - end - - # verified and the gpg user's profile doesn't exist anymore - gpg_user.destroy! - - visit project_commits_path(project, :'signed-commits') - - click_on 'Verified', match: :first - within '.popover' do - expect(page).to have_content 'This commit was signed with a verified signature.' - expect(page).to have_content 'Nannie Bernhard' - expect(page).to have_content 'nannie.bernhard@example.com' - expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}" - end - end - end end diff --git a/spec/features/dashboard/active_tab_spec.rb b/spec/features/dashboard/active_tab_spec.rb index ee2554cbd48..08d8cc7922b 100644 --- a/spec/features/dashboard/active_tab_spec.rb +++ b/spec/features/dashboard/active_tab_spec.rb @@ -7,9 +7,8 @@ RSpec.describe 'Dashboard Active Tab', js: true do shared_examples 'page has active tab' do |title| it "#{title} tab" do - find('.global-dropdown-toggle').click - expect(page).to have_selector('.global-dropdown-menu li.active', count: 1) - expect(find('.global-dropdown-menu li.active')).to have_content(title) + expect(page).to have_selector('.navbar-sub-nav li.active', count: 1) + expect(find('.navbar-sub-nav li.active')).to have_content(title) end end @@ -21,27 +20,19 @@ RSpec.describe 'Dashboard Active Tab', js: true do it_behaves_like 'page has active tab', 'Projects' end - context 'on dashboard issues' do - before do - visit issues_dashboard_path - end - - it_behaves_like 'page has active tab', 'Issues' - end - - context 'on dashboard merge requests' do + context 'on dashboard groups' do before do - visit merge_requests_dashboard_path + visit dashboard_groups_path end - it_behaves_like 'page has active tab', 'Merge Requests' + it_behaves_like 'page has active tab', 'Groups' end - context 'on dashboard groups' do + context 'on activity projects' do before do - visit dashboard_groups_path + visit activity_dashboard_path end - it_behaves_like 'page has active tab', 'Groups' + it_behaves_like 'page has active tab', 'Activity' end end diff --git a/spec/features/dashboard/issues_filter_spec.rb b/spec/features/dashboard/issues_filter_spec.rb index facb67ae787..ebc3d196118 100644 --- a/spec/features/dashboard/issues_filter_spec.rb +++ b/spec/features/dashboard/issues_filter_spec.rb @@ -50,7 +50,7 @@ feature 'Dashboard Issues filtering', :js do it 'updates atom feed link' do visit_issues(milestone_title: '', assignee_id: user.id) - link = find('.nav-controls a[title="Subscribe"]') + link = find('.breadcrumbs a[title="Subscribe"]') params = CGI.parse(URI.parse(link[:href]).query) auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) diff --git a/spec/features/dashboard/shortcuts_spec.rb b/spec/features/dashboard/shortcuts_spec.rb index 5f1f0c10339..e41bd7a8419 100644 --- a/spec/features/dashboard/shortcuts_spec.rb +++ b/spec/features/dashboard/shortcuts_spec.rb @@ -50,6 +50,6 @@ feature 'Dashboard shortcuts', :js do end def check_page_title(title) - expect(find('.header-content .title')).to have_content(title) + expect(find('.breadcrumbs-sub-title')).to have_content(title) end end diff --git a/spec/features/groups/group_name_toggle_spec.rb b/spec/features/groups/group_name_toggle_spec.rb deleted file mode 100644 index a7b8b702ab7..00000000000 --- a/spec/features/groups/group_name_toggle_spec.rb +++ /dev/null @@ -1,51 +0,0 @@ -require 'spec_helper' - -feature 'Group name toggle', js: true do - let(:group) { create(:group) } - let(:nested_group_1) { create(:group, parent: group) } - let(:nested_group_2) { create(:group, parent: nested_group_1) } - let(:nested_group_3) { create(:group, parent: nested_group_2) } - - SMALL_SCREEN = 300 - - before do - sign_in(create(:user)) - end - - it 'is not present if enough horizontal space' do - visit group_path(nested_group_3) - - container_width = page.evaluate_script("$('.title-container')[0].offsetWidth") - title_width = page.evaluate_script("$('.title')[0].offsetWidth") - - expect(container_width).to be > title_width - expect(page).not_to have_css('.group-name-toggle') - end - - it 'is present if the title is longer than the container', :nested_groups do - visit group_path(nested_group_3) - title_width = page.evaluate_script("$('.title')[0].offsetWidth") - - page_height = page.current_window.size[1] - page.current_window.resize_to(SMALL_SCREEN, page_height) - - find('.group-name-toggle') - container_width = page.evaluate_script("$('.title-container')[0].offsetWidth") - - expect(title_width).to be > container_width - end - - it 'should show the full group namespace when toggled', :nested_groups do - page_height = page.current_window.size[1] - page.current_window.resize_to(SMALL_SCREEN, page_height) - visit group_path(nested_group_3) - - expect(page).not_to have_content(group.name) - expect(page).to have_css('.group-path.hidable', visible: false) - - click_button '...' - - expect(page).to have_content(group.name) - expect(page).to have_css('.group-path.hidable', visible: true) - end -end diff --git a/spec/features/groups/group_settings_spec.rb b/spec/features/groups/group_settings_spec.rb index d0316cfb18d..b83bad3befb 100644 --- a/spec/features/groups/group_settings_spec.rb +++ b/spec/features/groups/group_settings_spec.rb @@ -65,14 +65,14 @@ feature 'Edit group settings' do update_path(new_group_path) visit new_project_full_path expect(current_path).to eq(new_project_full_path) - expect(find('h1.title')).to have_content(project.path) + expect(find('.breadcrumbs')).to have_content(project.path) end scenario 'the old project path redirects to the new path' do update_path(new_group_path) visit old_project_full_path expect(current_path).to eq(new_project_full_path) - expect(find('h1.title')).to have_content(project.path) + expect(find('.breadcrumbs')).to have_content(project.path) end end end diff --git a/spec/features/groups/merge_requests_spec.rb b/spec/features/groups/merge_requests_spec.rb index 9ba9f5686f7..2577d98df6f 100644 --- a/spec/features/groups/merge_requests_spec.rb +++ b/spec/features/groups/merge_requests_spec.rb @@ -25,7 +25,7 @@ feature 'Group merge requests page' do end it 'ignores archived merge request count badges in navbar' do - expect( page.find('[title="Merge Requests"] span.badge.count').text).to eq("1") + expect( page.find('[aria-label="Merge Requests"] span.badge.count').text).to eq("1") end it 'ignores archived merge request count badges in state-filters' do diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index 20f9818b08b..4ec2e7e6012 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -158,7 +158,7 @@ feature 'Group' do expect(page).to have_content 'successfully updated' expect(find('#group_name').value).to eq(new_name) - page.within ".navbar-gitlab" do + page.within ".breadcrumbs" do expect(page).to have_content new_name end end diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb index 134e618feac..a29acb30163 100644 --- a/spec/features/issues/award_emoji_spec.rb +++ b/spec/features/issues/award_emoji_spec.rb @@ -70,13 +70,13 @@ describe 'Awards Emoji' do it 'toggles the smiley emoji on a note', js: true do toggle_smiley_emoji(true) - within('.note-awards') do + within('.note-body') do expect(find(emoji_counter)).to have_text("1") end toggle_smiley_emoji(false) - within('.note-awards') do + within('.note-body') do expect(page).not_to have_selector(emoji_counter) end end diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index 22e7becff1a..3ea6e1c8863 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -1,26 +1,24 @@ require 'spec_helper' describe 'Filter issues', js: true do - include Devise::Test::IntegrationHelpers include FilteredSearchHelpers - let!(:group) { create(:group) } - let!(:project) { create(:project, group: group) } - let!(:user) { create(:user, username: 'joe', name: 'Joe') } - let!(:user2) { create(:user, username: 'jane') } - let!(:label) { create(:label, project: project) } - let!(:wontfix) { create(:label, project: project, title: "Won't fix") } + let(:project) { create(:project) } + + # NOTE: The short name here is actually important + # + # When the name is longer, the filtered search input can end up scrolling + # horizontally, and PhantomJS can't handle it. + let(:user) { create(:user, name: 'Ann') } let!(:bug_label) { create(:label, project: project, title: 'bug') } let!(:caps_sensitive_label) { create(:label, project: project, title: 'CaPs') } - let!(:milestone) { create(:milestone, title: "8", project: project, start_date: 2.days.ago) } let!(:multiple_words_label) { create(:label, project: project, title: "Two words") } - - let!(:closed_issue) { create(:issue, title: 'bug that is closed', project: project, state: :closed) } + let!(:milestone) { create(:milestone, title: "8", project: project, start_date: 2.days.ago) } def expect_no_issues_list page.within '.issues-list' do - expect(page).not_to have_selector('.issue') + expect(page).to have_no_selector('.issue') end end @@ -33,63 +31,62 @@ describe 'Filter issues', js: true do end end - def select_search_at_index(pos) - evaluate_script("el = document.querySelector('.filtered-search'); el.focus(); el.setSelectionRange(#{pos}, #{pos});") - end - before do - project.team << [user, :master] - project.team << [user2, :master] - group.add_developer(user) - group.add_developer(user2) + project.add_master(user) - sign_in(user) + user2 = create(:user) - create(:issue, project: project) - create(:issue, project: project, title: "Bug report 1") - create(:issue, project: project, title: "Bug report 2") - create(:issue, project: project, title: "issue with 'single quotes'") - create(:issue, project: project, title: "issue with \"double quotes\"") - create(:issue, project: project, title: "issue with !@\#{$%^&*()-+") - create(:issue, project: project, title: "issue by assignee", milestone: milestone, author: user, assignees: [user]) - create(:issue, project: project, title: "issue by assignee with searchTerm", milestone: milestone, author: user, assignees: [user]) + create(:issue, project: project, author: user2, title: "Bug report 1") + create(:issue, project: project, author: user2, title: "Bug report 2") - issue = create(:issue, + create(:issue, project: project, author: user, title: "issue by assignee", milestone: milestone, assignees: [user]) + create(:issue, project: project, author: user, title: "issue by assignee with searchTerm", milestone: milestone, assignees: [user]) + + create(:labeled_issue, title: "Bug 2", project: project, milestone: milestone, author: user, - assignees: [user]) - issue.labels << bug_label + assignees: [user], + labels: [bug_label]) - issue_with_caps_label = create(:issue, + create(:labeled_issue, title: "issue by assignee with searchTerm and label", project: project, milestone: milestone, author: user, - assignees: [user]) - issue_with_caps_label.labels << caps_sensitive_label + assignees: [user], + labels: [caps_sensitive_label]) - issue_with_everything = create(:issue, + create(:labeled_issue, title: "Bug report foo was possible", project: project, milestone: milestone, author: user, - assignees: [user]) - issue_with_everything.labels << bug_label - issue_with_everything.labels << caps_sensitive_label + assignees: [user], + labels: [bug_label, caps_sensitive_label]) + + create(:labeled_issue, title: "Issue with multiple words label", project: project, labels: [multiple_words_label]) - multiple_words_label_issue = create(:issue, title: "Issue with multiple words label", project: project) - multiple_words_label_issue.labels << multiple_words_label + sign_in(user) + visit project_issues_path(project) + end - future_milestone = create(:milestone, title: "future", project: project, due_date: Time.now + 1.month) + it 'filters by all available tokens' do + search_term = 'issue' - create(:issue, - title: "Issue with future milestone", - milestone: future_milestone, - project: project) + input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}") - visit project_issues_path(project) + wait_for_requests + + expect_tokens([ + assignee_token(user.name), + author_token(user.name), + label_token(caps_sensitive_label.title), + milestone_token(milestone.title) + ]) + expect_issues_list_count(1) + expect_filtered_search_input(search_term) end describe 'filter issues by author' do @@ -104,59 +101,6 @@ describe 'Filter issues', js: true do expect_filtered_search_input_empty end end - - context 'author with other filters' do - let(:search_term) { 'issue' } - - it 'filters issues by searched author and text' do - input_filtered_search("author:@#{user.username} #{search_term}") - - wait_for_requests - - expect_tokens([author_token(user.name)]) - expect_issues_list_count(3) - expect_filtered_search_input(search_term) - end - - it 'filters issues by searched author, assignee and text' do - input_filtered_search("author:@#{user.username} assignee:@#{user.username} #{search_term}") - - wait_for_requests - - expect_tokens([author_token(user.name), assignee_token(user.name)]) - expect_issues_list_count(3) - expect_filtered_search_input(search_term) - end - - it 'filters issues by searched author, assignee, label, and text' do - input_filtered_search("author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} #{search_term}") - - wait_for_requests - - expect_tokens([ - author_token(user.name), - assignee_token(user.name), - label_token(caps_sensitive_label.title) - ]) - expect_issues_list_count(1) - expect_filtered_search_input(search_term) - end - - it 'filters issues by searched author, assignee, label, milestone and text' do - input_filtered_search("author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}") - - wait_for_requests - - expect_tokens([ - author_token(user.name), - assignee_token(user.name), - label_token(caps_sensitive_label.title), - milestone_token(milestone.title) - ]) - expect_issues_list_count(1) - expect_filtered_search_input(search_term) - end - end end describe 'filter issues by assignee' do @@ -175,66 +119,13 @@ describe 'Filter issues', js: true do input_filtered_search('assignee:none') expect_tokens([assignee_token('none')]) - expect_issues_list_count(8, 1) + expect_issues_list_count(3) expect_filtered_search_input_empty end end - - context 'assignee with other filters' do - let(:search_term) { 'searchTerm' } - - it 'filters issues by searched assignee and text' do - input_filtered_search("assignee:@#{user.username} #{search_term}") - - wait_for_requests - - expect_tokens([assignee_token(user.name)]) - expect_issues_list_count(2) - expect_filtered_search_input(search_term) - end - - it 'filters issues by searched assignee, author and text' do - input_filtered_search("assignee:@#{user.username} author:@#{user.username} #{search_term}") - - wait_for_requests - - expect_tokens([assignee_token(user.name), author_token(user.name)]) - expect_issues_list_count(2) - expect_filtered_search_input(search_term) - end - - it 'filters issues by searched assignee, author, label, text' do - input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} #{search_term}") - - wait_for_requests - - expect_tokens([ - assignee_token(user.name), - author_token(user.name), - label_token(caps_sensitive_label.title) - ]) - expect_issues_list_count(1) - expect_filtered_search_input(search_term) - end - - it 'filters issues by searched assignee, author, label, milestone and text' do - input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}") - - expect_tokens([ - assignee_token(user.name), - author_token(user.name), - label_token(caps_sensitive_label.title), - milestone_token(milestone.title) - ]) - expect_issues_list_count(1) - expect_filtered_search_input(search_term) - end - end end describe 'filter issues by label' do - let(:search_term) { 'bug' } - context 'only label' do it 'filters issues by searched label' do input_filtered_search("label:~#{bug_label.title}") @@ -248,7 +139,7 @@ describe 'Filter issues', js: true do input_filtered_search('label:none') expect_tokens([label_token('none', false)]) - expect_issues_list_count(9, 1) + expect_issues_list_count(8) expect_filtered_search_input_empty end @@ -275,13 +166,13 @@ describe 'Filter issues', js: true do expect_filtered_search_input_empty end - it 'does not show issues' do + it 'does not show issues for unused labels' do new_label = create(:label, project: project, title: 'new_label') input_filtered_search("label:~#{new_label.title}") expect_tokens([label_token(new_label.title)]) - expect_no_issues_list() + expect_no_issues_list expect_filtered_search_input_empty end end @@ -344,95 +235,10 @@ describe 'Filter issues', js: true do end end - context 'label with other filters' do - it 'filters issues by searched label and text' do - input_filtered_search("label:~#{caps_sensitive_label.title} #{search_term}") - - expect_tokens([label_token(caps_sensitive_label.title)]) - expect_issues_list_count(1) - expect_filtered_search_input(search_term) - end - - it 'filters issues by searched label, author and text' do - input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} #{search_term}") - - wait_for_requests - - expect_tokens([label_token(caps_sensitive_label.title), author_token(user.name)]) - expect_issues_list_count(1) - expect_filtered_search_input(search_term) - end - - it 'filters issues by searched label, author, assignee and text' do - input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} #{search_term}") - - wait_for_requests - - expect_tokens([ - label_token(caps_sensitive_label.title), - author_token(user.name), - assignee_token(user.name) - ]) - expect_issues_list_count(1) - expect_filtered_search_input(search_term) - end - - it 'filters issues by searched label, author, assignee, milestone and text' do - input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} #{search_term}") - - expect_tokens([ - label_token(caps_sensitive_label.title), - author_token(user.name), - assignee_token(user.name), - milestone_token(milestone.title) - ]) - expect_issues_list_count(1) - expect_filtered_search_input(search_term) - end - end - context 'multiple labels with other filters' do - it 'filters issues by searched label, label2, and text' do - input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} #{search_term}") - - expect_tokens([ - label_token(bug_label.title), - label_token(caps_sensitive_label.title) - ]) - expect_issues_list_count(1) - expect_filtered_search_input(search_term) - end - - it 'filters issues by searched label, label2, author and text' do - input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} #{search_term}") - - wait_for_requests - - expect_tokens([ - label_token(bug_label.title), - label_token(caps_sensitive_label.title), - author_token(user.name) - ]) - expect_issues_list_count(1) - expect_filtered_search_input(search_term) - end - - it 'filters issues by searched label, label2, author, assignee and text' do - input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} #{search_term}") - - wait_for_requests - - expect_tokens([ - label_token(bug_label.title), - label_token(caps_sensitive_label.title), - author_token(user.name), - assignee_token(user.name) - ]) - expect_issues_list_count(1) - expect_filtered_search_input(search_term) - end - it 'filters issues by searched label, label2, author, assignee, milestone and text' do + search_term = 'bug' + input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} #{search_term}") wait_for_requests @@ -450,15 +256,10 @@ describe 'Filter issues', js: true do end context 'issue label clicked' do - before do + it 'filters and displays in search bar' do find('.issues-list .issue .issue-main-info .issuable-info a .label', text: multiple_words_label.title).click - end - it 'filters' do expect_issues_list_count(1) - end - - it 'displays in search bar' do expect_tokens([label_token("\"#{multiple_words_label.title}\"")]) expect_filtered_search_input_empty end @@ -479,11 +280,15 @@ describe 'Filter issues', js: true do input_filtered_search("milestone:none") expect_tokens([milestone_token('none', false)]) - expect_issues_list_count(7, 1) + expect_issues_list_count(3) expect_filtered_search_input_empty end it 'filters issues by upcoming milestones' do + create(:milestone, project: project, due_date: 1.month.from_now) do |future_milestone| + create(:issue, project: project, milestone: future_milestone, author: user) + end + input_filtered_search("milestone:upcoming") expect_tokens([milestone_token('upcoming', false)]) @@ -501,7 +306,7 @@ describe 'Filter issues', js: true do it 'filters issues by milestone containing special characters' do special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project) - create(:issue, title: "Issue with special character milestone", project: project, milestone: special_milestone) + create(:issue, project: project, milestone: special_milestone) input_filtered_search("milestone:%#{special_milestone.title}") @@ -510,70 +315,16 @@ describe 'Filter issues', js: true do expect_filtered_search_input_empty end - it 'does not show issues' do - new_milestone = create(:milestone, title: "new", project: project) + it 'does not show issues for unused milestones' do + new_milestone = create(:milestone, title: 'new', project: project) input_filtered_search("milestone:%#{new_milestone.title}") expect_tokens([milestone_token(new_milestone.title)]) - expect_no_issues_list() + expect_no_issues_list expect_filtered_search_input_empty end end - - context 'milestone with other filters' do - let(:search_term) { 'bug' } - - it 'filters issues by searched milestone and text' do - input_filtered_search("milestone:%#{milestone.title} #{search_term}") - - expect_tokens([milestone_token(milestone.title)]) - expect_issues_list_count(2) - expect_filtered_search_input(search_term) - end - - it 'filters issues by searched milestone, author and text' do - input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} #{search_term}") - - wait_for_requests - - expect_tokens([ - milestone_token(milestone.title), - author_token(user.name) - ]) - expect_issues_list_count(2) - expect_filtered_search_input(search_term) - end - - it 'filters issues by searched milestone, author, assignee and text' do - input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} #{search_term}") - - wait_for_requests - - expect_tokens([ - milestone_token(milestone.title), - author_token(user.name), - assignee_token(user.name) - ]) - expect_issues_list_count(2) - expect_filtered_search_input(search_term) - end - - it 'filters issues by searched milestone, author, assignee, label and text' do - input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} #{search_term}") - - wait_for_requests - - expect_tokens([ - milestone_token(milestone.title), - author_token(user.name), - assignee_token(user.name), - label_token(bug_label.title) - ]) - expect_issues_list_count(2) - expect_filtered_search_input(search_term) - end - end end describe 'filter issues by text' do @@ -582,7 +333,7 @@ describe 'Filter issues', js: true do search = 'Bug' input_filtered_search(search) - expect_issues_list_count(4, 1) + expect_issues_list_count(4) expect_filtered_search_input(search) end @@ -603,112 +354,50 @@ describe 'Filter issues', js: true do end it 'filters issues by searched text containing single quotes' do - search = '\'single quotes\'' + issue = create(:issue, project: project, author: user, title: "issue with 'single quotes'") + + search = "'single quotes'" input_filtered_search(search) expect_issues_list_count(1) expect_filtered_search_input(search) + expect(page).to have_content(issue.title) end it 'filters issues by searched text containing double quotes' do + issue = create(:issue, project: project, author: user, title: "issue with \"double quotes\"") + search = '"double quotes"' input_filtered_search(search) expect_issues_list_count(1) expect_filtered_search_input(search) + expect(page).to have_content(issue.title) end it 'filters issues by searched text containing special characters' do + issue = create(:issue, project: project, author: user, title: "issue with !@\#{$%^&*()-+") + search = '!@#{$%^&*()-+' input_filtered_search(search) expect_issues_list_count(1) expect_filtered_search_input(search) + expect(page).to have_content(issue.title) end it 'does not show any issues' do search = 'testing' input_filtered_search(search) - expect_no_issues_list() + expect_no_issues_list expect_filtered_search_input(search) end end context 'searched text with other filters' do - it 'filters issues by searched text and author' do - # After searching, all search terms are placed at the end - input_filtered_search("bug author:@#{user.username}") - - expect_issues_list_count(2) - expect_filtered_search_input('bug') - end - - it 'filters issues by searched text, author and more text' do - input_filtered_search("bug author:@#{user.username} report") - - expect_issues_list_count(1) - expect_filtered_search_input('bug report') - end - - it 'filters issues by searched text, author and assignee' do - input_filtered_search("bug author:@#{user.username} assignee:@#{user.username}") - - expect_issues_list_count(2) - expect_filtered_search_input('bug') - end - - it 'filters issues by searched text, author, more text and assignee' do - input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username}") - - expect_issues_list_count(1) - expect_filtered_search_input('bug report') - end - - it 'filters issues by searched text, author, more text, assignee and even more text' do - input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} foo") - - expect_issues_list_count(1) - expect_filtered_search_input('bug report foo') - end - - it 'filters issues by searched text, author, assignee and label' do - input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title}") - - expect_issues_list_count(2) - expect_filtered_search_input('bug') - end - - it 'filters issues by searched text, author, text, assignee, text, label and text' do - input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} report label:~#{bug_label.title} foo") - - expect_issues_list_count(1) - expect_filtered_search_input('bug report foo') - end - - it 'filters issues by searched text, author, assignee, label and milestone' do - input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title}") - - expect_issues_list_count(2) - expect_filtered_search_input('bug') - end - - it 'filters issues by searched text, author, text, assignee, text, label, text, milestone and text' do - input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} report label:~#{bug_label.title} milestone:%#{milestone.title} foo") - - expect_issues_list_count(1) - expect_filtered_search_input('bug report foo') - end - - it 'filters issues by searched text, author, assignee, multiple labels and milestone' do - input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title}") - - expect_issues_list_count(1) - expect_filtered_search_input('bug') - end - it 'filters issues by searched text, author, text, assignee, text, label1, text, label2, text, milestone and text' do - input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} report label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} foo") + input_filtered_search("bug author:@#{user.username} report label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} foo") expect_issues_list_count(1) expect_filtered_search_input('bug report foo') @@ -746,7 +435,9 @@ describe 'Filter issues', js: true do end end - describe 'retains filter when switching issue states' do + describe 'switching issue states' do + let!(:closed_issue) { create(:issue, :closed, project: project, title: 'closed bug') } + before do input_filtered_search('bug') @@ -754,25 +445,21 @@ describe 'Filter issues', js: true do expect_issues_list_count(4, 1) end - it 'open state' do + it 'maintains filter' do + # Closed find('.issues-state-filters [data-state="closed"]').click wait_for_requests + expect(page).to have_selector('.issues-list .issue', count: 1) + expect(page).to have_link(closed_issue.title) + + # Opened find('.issues-state-filters [data-state="opened"]').click wait_for_requests expect(page).to have_selector('.issues-list .issue', count: 4) - end - it 'closed state' do - find('.issues-state-filters [data-state="closed"]').click - wait_for_requests - - expect(page).to have_selector('.issues-list .issue', count: 1) - expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(closed_issue.title) - end - - it 'all state' do + # All find('.issues-state-filters [data-state="all"]').click wait_for_requests @@ -781,34 +468,39 @@ describe 'Filter issues', js: true do end describe 'RSS feeds' do - it 'updates atom feed link for project issues' do - visit project_issues_path(project, milestone_title: milestone.title, assignee_id: user.id) - link = find_link('Subscribe') - params = CGI.parse(URI.parse(link[:href]).query) - auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) - auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) - - expect(params).to include('rss_token' => [user.rss_token]) - expect(params).to include('milestone_title' => [milestone.title]) - expect(params).to include('assignee_id' => [user.id.to_s]) - expect(auto_discovery_params).to include('rss_token' => [user.rss_token]) - expect(auto_discovery_params).to include('milestone_title' => [milestone.title]) - expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) + let(:group) { create(:group) } + let(:project) { create(:project, group: group) } + + before do + group.add_developer(user) + end + + shared_examples 'updates atom feed link' do |type| + it "for #{type}" do + visit path + + link = find_link('Subscribe') + params = CGI.parse(URI.parse(link[:href]).query) + auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) + auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) + + expected = { + 'rss_token' => [user.rss_token], + 'milestone_title' => [milestone.title], + 'assignee_id' => [user.id.to_s] + } + + expect(params).to include(expected) + expect(auto_discovery_params).to include(expected) + end + end + + it_behaves_like 'updates atom feed link', :project do + let(:path) { project_issues_path(project, milestone_title: milestone.title, assignee_id: user.id) } end - it 'updates atom feed link for group issues' do - visit issues_group_path(group, milestone_title: milestone.title, assignee_id: user.id) - link = find_link('Subscribe') - params = CGI.parse(URI.parse(link[:href]).query) - auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) - auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) - - expect(params).to include('rss_token' => [user.rss_token]) - expect(params).to include('milestone_title' => [milestone.title]) - expect(params).to include('assignee_id' => [user.id.to_s]) - expect(auto_discovery_params).to include('rss_token' => [user.rss_token]) - expect(auto_discovery_params).to include('milestone_title' => [milestone.title]) - expect(auto_discovery_params).to include('assignee_id' => [user.id.to_s]) + it_behaves_like 'updates atom feed link', :group do + let(:path) { issues_group_path(group, milestone_title: milestone.title, assignee_id: user.id) } end end @@ -821,7 +513,7 @@ describe 'Filter issues', js: true do input_filtered_search("milestone:", submit: false) within('#js-dropdown-milestone') do - expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 2) + expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 1) end end @@ -829,7 +521,7 @@ describe 'Filter issues', js: true do input_filtered_search("label:", submit: false) within('#js-dropdown-label') do - expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 5) + expect(page).to have_selector('.filter-dropdown .filter-dropdown-item', count: 3) end end end diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb index 14a555fde10..4ae54fd6f4e 100644 --- a/spec/features/issues/filtered_search/visual_tokens_spec.rb +++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb @@ -28,6 +28,8 @@ describe 'Visual tokens', js: true do sign_in(user) create(:issue, project: project) + allow_any_instance_of(ApplicationHelper).to receive(:collapsed_sidebar?).and_return(true) + visit project_issues_path(project) end diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb index 2c1ba207ede..62e9d3a9f91 100644 --- a/spec/features/issues/gfm_autocomplete_spec.rb +++ b/spec/features/issues/gfm_autocomplete_spec.rb @@ -28,8 +28,8 @@ feature 'GFM autocomplete', js: true do it 'opens autocomplete menu when field starts with text' do page.within '.timeline-content-form' do - find('#note_note').native.send_keys('') - find('#note_note').native.send_keys('@') + find('#note-body').native.send_keys('') + find('#note-body').native.send_keys('@') end expect(page).to have_selector('.atwho-container') @@ -37,8 +37,8 @@ feature 'GFM autocomplete', js: true do it 'doesnt open autocomplete menu character is prefixed with text' do page.within '.timeline-content-form' do - find('#note_note').native.send_keys('testing') - find('#note_note').native.send_keys('@') + find('#note-body').native.send_keys('testing') + find('#note-body').native.send_keys('@') end expect(page).not_to have_selector('.atwho-view') @@ -46,8 +46,8 @@ feature 'GFM autocomplete', js: true do it 'doesnt select the first item for non-assignee dropdowns' do page.within '.timeline-content-form' do - find('#note_note').native.send_keys('') - find('#note_note').native.send_keys(':') + find('#note-body').native.send_keys('') + find('#note-body').native.send_keys(':') end expect(page).to have_selector('.atwho-container') @@ -58,7 +58,7 @@ feature 'GFM autocomplete', js: true do end it 'does not open autocomplete menu when ":" is prefixed by a number and letters' do - note = find('#note_note') + note = find('#note-body') # Number. page.within '.timeline-content-form' do @@ -86,8 +86,8 @@ feature 'GFM autocomplete', js: true do it 'selects the first item for assignee dropdowns' do page.within '.timeline-content-form' do - find('#note_note').native.send_keys('') - find('#note_note').native.send_keys('@') + find('#note-body').native.send_keys('') + find('#note-body').native.send_keys('@') end expect(page).to have_selector('.atwho-container') @@ -99,8 +99,8 @@ feature 'GFM autocomplete', js: true do it 'includes items for assignee dropdowns with non-ASCII characters in name' do page.within '.timeline-content-form' do - find('#note_note').native.send_keys('') - find('#note_note').native.send_keys("@#{user.name[0...8]}") + find('#note-body').native.send_keys('') + find('#note-body').native.send_keys("@#{user.name[0...8]}") end expect(page).to have_selector('.atwho-container') @@ -112,8 +112,8 @@ feature 'GFM autocomplete', js: true do it 'selects the first item for non-assignee dropdowns if a query is entered' do page.within '.timeline-content-form' do - find('#note_note').native.send_keys('') - find('#note_note').native.send_keys(':1') + find('#note-body').native.send_keys('') + find('#note-body').native.send_keys(':1') end expect(page).to have_selector('.atwho-container') @@ -125,7 +125,7 @@ feature 'GFM autocomplete', js: true do context 'if a selected value has special characters' do it 'wraps the result in double quotes' do - note = find('#note_note') + note = find('#note-body') page.within '.timeline-content-form' do note.native.send_keys('') note.native.send_keys("~#{label.title[0]}") @@ -138,7 +138,7 @@ feature 'GFM autocomplete', js: true do end it "shows dropdown after a new line" do - note = find('#note_note') + note = find('#note-body') page.within '.timeline-content-form' do note.native.send_keys('test') note.native.send_keys(:enter) @@ -150,7 +150,7 @@ feature 'GFM autocomplete', js: true do end it "does not show dropdown when preceded with a special character" do - note = find('#note_note') + note = find('#note-body') page.within '.timeline-content-form' do note.native.send_keys('') note.native.send_keys("@") @@ -168,7 +168,7 @@ feature 'GFM autocomplete', js: true do end it "does not throw an error if no labels exist" do - note = find('#note_note') + note = find('#note-body') page.within '.timeline-content-form' do note.native.send_keys('') note.native.send_keys('~') @@ -179,7 +179,7 @@ feature 'GFM autocomplete', js: true do end it 'doesn\'t wrap for assignee values' do - note = find('#note_note') + note = find('#note-body') page.within '.timeline-content-form' do note.native.send_keys('') note.native.send_keys("@#{user.username[0]}") @@ -192,7 +192,7 @@ feature 'GFM autocomplete', js: true do end it 'doesn\'t wrap for emoji values' do - note = find('#note_note') + note = find('#note-body') page.within '.timeline-content-form' do note.native.send_keys('') note.native.send_keys(":cartwheel") @@ -206,7 +206,7 @@ feature 'GFM autocomplete', js: true do it 'doesn\'t open autocomplete after non-word character' do page.within '.timeline-content-form' do - find('#note_note').native.send_keys("@#{user.username[0..2]}!") + find('#note-body').native.send_keys("@#{user.username[0..2]}!") end expect(page).not_to have_selector('.atwho-view') @@ -214,14 +214,14 @@ feature 'GFM autocomplete', js: true do it 'doesn\'t open autocomplete if there is no space before' do page.within '.timeline-content-form' do - find('#note_note').native.send_keys("hello:#{user.username[0..2]}") + find('#note-body').native.send_keys("hello:#{user.username[0..2]}") end expect(page).not_to have_selector('.atwho-view') end it 'triggers autocomplete after selecting a quick action' do - note = find('#note_note') + note = find('#note-body') page.within '.timeline-content-form' do note.native.send_keys('') note.native.send_keys('/as') diff --git a/spec/features/issues/markdown_toolbar_spec.rb b/spec/features/issues/markdown_toolbar_spec.rb index 8c23fcd483b..634ea111dc1 100644 --- a/spec/features/issues/markdown_toolbar_spec.rb +++ b/spec/features/issues/markdown_toolbar_spec.rb @@ -12,26 +12,26 @@ feature 'Issue markdown toolbar', js: true do end it "doesn't include first new line when adding bold" do - find('#note_note').native.send_keys('test') - find('#note_note').native.send_key(:enter) - find('#note_note').native.send_keys('bold') + find('#note-body').native.send_keys('test') + find('#note-body').native.send_key(:enter) + find('#note-body').native.send_keys('bold') - page.evaluate_script('document.querySelectorAll(".js-main-target-form #note_note")[0].setSelectionRange(4, 9)') + page.evaluate_script('document.querySelectorAll(".js-main-target-form #note-body")[0].setSelectionRange(4, 9)') first('.toolbar-btn').click - expect(find('#note_note')[:value]).to eq("test\n**bold**\n") + expect(find('#note-body')[:value]).to eq("test\n**bold**\n") end it "doesn't include first new line when adding underline" do - find('#note_note').native.send_keys('test') - find('#note_note').native.send_key(:enter) - find('#note_note').native.send_keys('underline') + find('#note-body').native.send_keys('test') + find('#note-body').native.send_key(:enter) + find('#note-body').native.send_keys('underline') - page.evaluate_script('document.querySelectorAll(".js-main-target-form #note_note")[0].setSelectionRange(4, 50)') + page.evaluate_script('document.querySelectorAll(".js-main-target-form #note-body")[0].setSelectionRange(4, 50)') find('.toolbar-btn:nth-child(2)').click - expect(find('#note_note')[:value]).to eq("test\n*underline*\n") + expect(find('#note-body')[:value]).to eq("test\n*underline*\n") end end diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb index 494c309c9ea..b2724945da4 100644 --- a/spec/features/issues/move_spec.rb +++ b/spec/features/issues/move_spec.rb @@ -15,11 +15,11 @@ feature 'issue move to another project' do background do old_project.team << [user, :guest] - edit_issue(issue) + visit issue_path(issue) end scenario 'moving issue to another project not allowed' do - expect(page).to have_no_selector('#move_to_project_id') + expect(page).to have_no_selector('.js-sidebar-move-issue-block') end end @@ -34,12 +34,14 @@ feature 'issue move to another project' do old_project.team << [user, :reporter] new_project.team << [user, :reporter] - edit_issue(issue) + visit issue_path(issue) end scenario 'moving issue to another project', js: true do - find('#issuable-move', visible: false).set(new_project.id) - click_button('Save changes') + find('.js-move-issue').trigger('click') + wait_for_requests + all('.js-move-issue-dropdown-item')[0].click + find('.js-move-issue-confirmation-button').click expect(page).to have_content("Text with #{cross_reference}#{mr.to_reference}") expect(page).to have_content("moved from #{cross_reference}#{issue.to_reference}") @@ -50,13 +52,12 @@ feature 'issue move to another project' do scenario 'searching project dropdown', js: true do new_project_search.team << [user, :reporter] - page.within '.detail-page-description' do - first('.select2-choice').click - end + find('.js-move-issue').trigger('click') + wait_for_requests - fill_in('s2id_autogen1_search', with: new_project_search.name) + page.within '.js-sidebar-move-issue-block' do + fill_in('sidebar-move-issue-dropdown-search', with: new_project_search.name) - page.within '.select2-drop' do expect(page).to have_content(new_project_search.name) expect(page).not_to have_content(new_project.name) end @@ -68,10 +69,10 @@ feature 'issue move to another project' do background { another_project.team << [user, :guest] } scenario 'browsing projects in projects select' do - click_link 'Move to a different project' + find('.js-move-issue').trigger('click') + wait_for_requests - page.within '.select2-results' do - expect(page).to have_content 'No project' + page.within '.js-sidebar-move-issue-block' do expect(page).to have_content new_project.name_with_namespace end end @@ -89,11 +90,6 @@ feature 'issue move to another project' do end end - def edit_issue(issue) - visit issue_path(issue) - page.within('.issuable-actions') { first(:link, 'Edit').click } - end - def issue_path(issue) project_issue_path(issue.project, issue) end diff --git a/spec/features/issues/note_polling_spec.rb b/spec/features/issues/note_polling_spec.rb index 62dbc3efb01..793572851da 100644 --- a/spec/features/issues/note_polling_spec.rb +++ b/spec/features/issues/note_polling_spec.rb @@ -13,7 +13,7 @@ feature 'Issue notes polling', :js do it 'displays the new comment' do note = create(:note, noteable: issue, project: project, note: 'Looks good!') - page.execute_script('notes.refresh();') + wait_for_requests expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!') end @@ -31,16 +31,6 @@ feature 'Issue notes polling', :js do visit project_issue_path(project, issue) end - it 'has .original-note-content to compare against' do - expect(page).to have_selector("#note_#{existing_note.id}", text: note_text) - expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false) - - update_note(existing_note, updated_text) - - expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text) - expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false) - end - it 'displays the updated content' do expect(page).to have_selector("#note_#{existing_note.id}", text: note_text) @@ -49,24 +39,14 @@ feature 'Issue notes polling', :js do expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text) end - it 'when editing but have not changed anything, and an update comes in, show the updated content in the textarea' do + it 'when editing but have not changed anything, and an update comes in, show warning and does not update the note' do click_edit_action(existing_note) expect(page).to have_field("note[note]", with: note_text) update_note(existing_note, updated_text) - expect(page).to have_field("note[note]", with: updated_text) - end - - it 'when editing but you changed some things, and an update comes in, show a warning' do - click_edit_action(existing_note) - - expect(page).to have_field("note[note]", with: note_text) - - find("#note_#{existing_note.id} .js-note-text").set('something random') - update_note(existing_note, updated_text) - + expect(page).not_to have_field("note[note]", with: updated_text) expect(page).to have_selector(".alert") end @@ -75,8 +55,6 @@ feature 'Issue notes polling', :js do expect(page).to have_field("note[note]", with: note_text) - find("#note_#{existing_note.id} .js-note-text").set('something random') - update_note(existing_note, updated_text) find("#note_#{existing_note.id} .note-edit-cancel").click @@ -97,14 +75,12 @@ feature 'Issue notes polling', :js do visit project_issue_path(project, issue) end - it 'has .original-note-content to compare against' do + it 'displays the updated content' do expect(page).to have_selector("#note_#{existing_note.id}", text: note_text) - expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false) update_note(existing_note, updated_text) expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text) - expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false) end end @@ -118,16 +94,15 @@ feature 'Issue notes polling', :js do visit project_issue_path(project, issue) end - it 'has .original-note-content to compare against' do + it 'shows the system note' do expect(page).to have_selector("#note_#{system_note.id}", text: note_text) - expect(page).to have_selector("#note_#{system_note.id} .original-note-content", count: 1, visible: false) end end end def update_note(note, new_text) note.update(note: new_text) - page.execute_script('notes.refresh();') + wait_for_requests end def click_edit_action(note) diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb index 4b63cc844f3..9261acda9dc 100644 --- a/spec/features/issues/user_uses_slash_commands_spec.rb +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -155,5 +155,114 @@ feature 'Issues > User uses quick actions', js: true do end end end + + describe 'move the issue to another project' do + let(:issue) { create(:issue, project: project) } + + context 'when the project is valid', js: true do + let(:target_project) { create(:project, :public) } + + before do + target_project.team << [user, :master] + sign_in(user) + visit project_issue_path(project, issue) + end + + it 'moves the issue' do + write_note("/move #{target_project.full_path}") + + expect(page).to have_content 'Commands applied' + expect(issue.reload).to be_closed + + visit project_issue_path(target_project, issue) + + expect(page).to have_content 'Issues 1' + end + end + + context 'when the project is valid but the user not authorized', js: true do + let(:project_unauthorized) {create(:project, :public)} + + before do + sign_in(user) + visit project_issue_path(project, issue) + end + + it 'does not move the issue' do + write_note("/move #{project_unauthorized.full_path}") + + expect(page).not_to have_content 'Commands applied' + expect(issue.reload).to be_open + end + end + + context 'when the project is invalid', js: true do + before do + sign_in(user) + visit project_issue_path(project, issue) + end + + it 'does not move the issue' do + write_note("/move not/valid") + + expect(page).not_to have_content 'Commands applied' + expect(issue.reload).to be_open + end + end + + context 'when the user issues multiple commands', js: true do + let(:target_project) { create(:project, :public) } + let(:milestone) { create(:milestone, title: '1.0', project: project) } + let(:target_milestone) { create(:milestone, title: '1.0', project: target_project) } + let(:bug) { create(:label, project: project, title: 'bug') } + let(:wontfix) { create(:label, project: project, title: 'wontfix') } + let(:bug_target) { create(:label, project: target_project, title: 'bug') } + let(:wontfix_target) { create(:label, project: target_project, title: 'wontfix') } + + before do + target_project.team << [user, :master] + sign_in(user) + visit project_issue_path(project, issue) + end + + it 'applies the commands to both issues and moves the issue' do + write_note("/label ~#{bug.title} ~#{wontfix.title}\n/milestone %\"#{milestone.title}\"\n/move #{target_project.full_path}") + + expect(page).to have_content 'Commands applied' + expect(issue.reload).to be_closed + + visit project_issue_path(target_project, issue) + + expect(page).to have_content 'bug' + expect(page).to have_content 'wontfix' + expect(page).to have_content '1.0' + + visit project_issue_path(project, issue) + expect(page).to have_content 'Closed' + expect(page).to have_content 'bug' + expect(page).to have_content 'wontfix' + expect(page).to have_content '1.0' + end + + it 'moves the issue and applies the commands to both issues' do + write_note("/move #{target_project.full_path}\n/label ~#{bug.title} ~#{wontfix.title}\n/milestone %\"#{milestone.title}\"") + + expect(page).to have_content 'Commands applied' + expect(issue.reload).to be_closed + + visit project_issue_path(target_project, issue) + + expect(page).to have_content 'bug' + expect(page).to have_content 'wontfix' + expect(page).to have_content '1.0' + + visit project_issue_path(project, issue) + expect(page).to have_content 'Closed' + expect(page).to have_content 'bug' + expect(page).to have_content 'wontfix' + expect(page).to have_content '1.0' + end + end + end end end diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 9b91495ee3d..6c070e44269 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -271,17 +271,21 @@ describe 'Issues' do it 'filters by none' do visit project_issues_path(project, due_date: Issue::NoDueDate.name) - expect(page).not_to have_content('foo') - expect(page).not_to have_content('bar') - expect(page).to have_content('baz') + page.within '.issues-holder' do + expect(page).not_to have_content('foo') + expect(page).not_to have_content('bar') + expect(page).to have_content('baz') + end end it 'filters by any' do visit project_issues_path(project, due_date: Issue::AnyDueDate.name) - expect(page).to have_content('foo') - expect(page).to have_content('bar') - expect(page).to have_content('baz') + page.within '.issues-holder' do + expect(page).to have_content('foo') + expect(page).to have_content('bar') + expect(page).to have_content('baz') + end end it 'filters by due this week' do @@ -291,9 +295,11 @@ describe 'Issues' do visit project_issues_path(project, due_date: Issue::DueThisWeek.name) - expect(page).to have_content('foo') - expect(page).to have_content('bar') - expect(page).not_to have_content('baz') + page.within '.issues-holder' do + expect(page).to have_content('foo') + expect(page).to have_content('bar') + expect(page).not_to have_content('baz') + end end it 'filters by due this month' do @@ -303,9 +309,11 @@ describe 'Issues' do visit project_issues_path(project, due_date: Issue::DueThisMonth.name) - expect(page).to have_content('foo') - expect(page).to have_content('bar') - expect(page).not_to have_content('baz') + page.within '.issues-holder' do + expect(page).to have_content('foo') + expect(page).to have_content('bar') + expect(page).not_to have_content('baz') + end end it 'filters by overdue' do @@ -315,9 +323,11 @@ describe 'Issues' do visit project_issues_path(project, due_date: Issue::Overdue.name) - expect(page).not_to have_content('foo') - expect(page).not_to have_content('bar') - expect(page).to have_content('baz') + page.within '.issues-holder' do + expect(page).not_to have_content('foo') + expect(page).not_to have_content('bar') + expect(page).to have_content('baz') + end end end @@ -567,7 +577,9 @@ describe 'Issues' do it 'redirects to signin then back to new issue after signin' do visit project_issues_path(project) - click_link 'New issue' + page.within '.breadcrumbs' do + click_link 'New issue' + end expect(current_path).to eq new_user_session_path diff --git a/spec/features/merge_requests/create_new_mr_spec.rb b/spec/features/merge_requests/create_new_mr_spec.rb index d7f3d91e625..96e8027a54d 100644 --- a/spec/features/merge_requests/create_new_mr_spec.rb +++ b/spec/features/merge_requests/create_new_mr_spec.rb @@ -13,7 +13,9 @@ feature 'Create New Merge Request', js: true do it 'selects the source branch sha when a tag with the same name exists' do visit project_merge_requests_path(project) - click_link 'New merge request' + page.within '.content' do + click_link 'New merge request' + end expect(page).to have_content('Source branch') expect(page).to have_content('Target branch') @@ -26,7 +28,9 @@ feature 'Create New Merge Request', js: true do it 'selects the target branch sha when a tag with the same name exists' do visit project_merge_requests_path(project) - click_link 'New merge request' + page.within '.content' do + click_link 'New merge request' + end expect(page).to have_content('Source branch') expect(page).to have_content('Target branch') @@ -40,7 +44,9 @@ feature 'Create New Merge Request', js: true do it 'generates a diff for an orphaned branch' do visit project_merge_requests_path(project) - page.has_link?('New Merge Request') ? click_link("New Merge Request") : click_link('New merge request') + page.within '.content' do + click_link 'New merge request' + end expect(page).to have_content('Source branch') expect(page).to have_content('Target branch') diff --git a/spec/features/merge_requests/diff_notes_avatars_spec.rb b/spec/features/merge_requests/diff_notes_avatars_spec.rb index 13721b72584..166e98238e8 100644 --- a/spec/features/merge_requests/diff_notes_avatars_spec.rb +++ b/spec/features/merge_requests/diff_notes_avatars_spec.rb @@ -21,6 +21,8 @@ feature 'Diff note avatars', js: true do before do project.team << [user, :master] sign_in user + + allow_any_instance_of(ApplicationHelper).to receive(:collapsed_sidebar?).and_return(true) end context 'discussion tab' do diff --git a/spec/features/merge_requests/diffs_spec.rb b/spec/features/merge_requests/diffs_spec.rb index a8f5dc275e4..e9068f722d5 100644 --- a/spec/features/merge_requests/diffs_spec.rb +++ b/spec/features/merge_requests/diffs_spec.rb @@ -88,7 +88,7 @@ feature 'Diffs URL', js: true do visit diffs_project_merge_request_path(project, merge_request) # Throws `Capybara::Poltergeist::InvalidSelector` if we try to use `#hash` syntax - find("[id=\"#{changelog_id}\"] .js-edit-blob").click + find("[id=\"#{changelog_id}\"] .js-edit-blob").trigger('click') expect(page).to have_selector('.js-fork-suggestion-button', count: 1) expect(page).to have_selector('.js-cancel-fork-suggestion-button', count: 1) diff --git a/spec/features/merge_requests/mini_pipeline_graph_spec.rb b/spec/features/merge_requests/mini_pipeline_graph_spec.rb index 24abebb3995..27b5e3cfec6 100644 --- a/spec/features/merge_requests/mini_pipeline_graph_spec.rb +++ b/spec/features/merge_requests/mini_pipeline_graph_spec.rb @@ -70,7 +70,7 @@ feature 'Mini Pipeline Graph', :js do it 'should show tooltip when hovered' do toggle.hover - expect(toggle.find(:xpath, '..')).to have_selector('.tooltip') + expect(page).to have_selector('.tooltip') end end @@ -117,7 +117,7 @@ feature 'Mini Pipeline Graph', :js do it 'should show tooltip when hovered' do build_item.hover - expect(build_item.find(:xpath, '..')).to have_selector('.tooltip') + expect(page).to have_selector('.tooltip') end end end diff --git a/spec/features/merge_requests/user_posts_diff_notes_spec.rb b/spec/features/merge_requests/user_posts_diff_notes_spec.rb index 9cee21bc333..996f9636491 100644 --- a/spec/features/merge_requests/user_posts_diff_notes_spec.rb +++ b/spec/features/merge_requests/user_posts_diff_notes_spec.rb @@ -6,6 +6,8 @@ feature 'Merge requests > User posts diff notes', :js do let(:project) { merge_request.source_project } before do + allow_any_instance_of(ApplicationHelper).to receive(:collapsed_sidebar?).and_return(true) + project.add_developer(user) sign_in(user) end @@ -95,6 +97,16 @@ feature 'Merge requests > User posts diff notes', :js do visit diffs_project_merge_request_path(project, merge_request, view: 'inline') end + context 'after deleteing a note' do + it 'allows commenting' do + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]')) + + first('.js-note-delete', visible: false).trigger('click') + + should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]')) + end + end + context 'with a new line' do it 'allows commenting' do should_allow_commenting(find('[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"]')) diff --git a/spec/features/participants_autocomplete_spec.rb b/spec/features/participants_autocomplete_spec.rb index a22d548eef3..96f6df587e1 100644 --- a/spec/features/participants_autocomplete_spec.rb +++ b/spec/features/participants_autocomplete_spec.rb @@ -11,10 +11,14 @@ feature 'Member autocomplete', :js do sign_in(user) end - shared_examples "open suggestions when typing @" do + shared_examples "open suggestions when typing @" do |resource_name| before do page.within('.new-note') do - find('#note_note').send_keys('@') + if resource_name == 'issue' + find('#note-body').send_keys('@') + else + find('#note_note').send_keys('@') + end end end @@ -32,7 +36,7 @@ feature 'Member autocomplete', :js do visit project_issue_path(project, noteable) end - include_examples "open suggestions when typing @" + include_examples "open suggestions when typing @", 'issue' end context 'adding a new note on a Merge Request' do @@ -45,7 +49,7 @@ feature 'Member autocomplete', :js do visit project_merge_request_path(project, noteable) end - include_examples "open suggestions when typing @" + include_examples "open suggestions when typing @", 'merge_request' end context 'adding a new note on a Commit' do @@ -60,6 +64,6 @@ feature 'Member autocomplete', :js do visit project_commit_path(project, noteable) end - include_examples "open suggestions when typing @" + include_examples "open suggestions when typing @", 'commit' end end diff --git a/spec/features/profiles/account_spec.rb b/spec/features/profiles/account_spec.rb index dcd0449dbcb..171e061e60e 100644 --- a/spec/features/profiles/account_spec.rb +++ b/spec/features/profiles/account_spec.rb @@ -43,14 +43,14 @@ feature 'Profile > Account' do update_username(new_username) visit new_project_path expect(current_path).to eq(new_project_path) - expect(find('h1.title')).to have_content(project.path) + expect(find('.breadcrumbs-sub-title')).to have_content(project.path) end scenario 'the old project path redirects to the new path' do update_username(new_username) visit old_project_path expect(current_path).to eq(new_project_path) - expect(find('h1.title')).to have_content(project.path) + expect(find('.breadcrumbs-sub-title')).to have_content(project.path) end end end diff --git a/spec/features/profiles/gpg_keys_spec.rb b/spec/features/profiles/gpg_keys_spec.rb index 6edc482b47e..623e4f341c5 100644 --- a/spec/features/profiles/gpg_keys_spec.rb +++ b/spec/features/profiles/gpg_keys_spec.rb @@ -42,7 +42,7 @@ feature 'Profile > GPG Keys' do scenario 'User revokes a key via the key index' do gpg_key = create :gpg_key, user: user, key: GpgHelpers::User2.public_key - gpg_signature = create :gpg_signature, gpg_key: gpg_key, valid_signature: true + gpg_signature = create :gpg_signature, gpg_key: gpg_key, verification_status: :verified visit profile_gpg_keys_path @@ -51,7 +51,7 @@ feature 'Profile > GPG Keys' do expect(page).to have_content('Your GPG keys (0)') expect(gpg_signature.reload).to have_attributes( - valid_signature: false, + verification_status: 'unknown_key', gpg_key: nil ) end diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb index 6541ea6bf57..aa71c4dbba4 100644 --- a/spec/features/profiles/keys_spec.rb +++ b/spec/features/profiles/keys_spec.rb @@ -28,6 +28,23 @@ feature 'Profile > SSH Keys' do expect(page).to have_content("Title: #{attrs[:title]}") expect(page).to have_content(attrs[:key]) end + + context 'when only DSA and ECDSA keys are allowed' do + before do + forbidden = ApplicationSetting::FORBIDDEN_KEY_VALUE + stub_application_setting(rsa_key_restriction: forbidden, ed25519_key_restriction: forbidden) + end + + scenario 'shows a validation error' do + attrs = attributes_for(:key) + + fill_in('Key', with: attrs[:key]) + fill_in('Title', with: attrs[:title]) + click_button('Add key') + + expect(page).to have_content('Key type is forbidden. Must be DSA or ECDSA') + end + end end scenario 'User sees their keys' do diff --git a/spec/features/profiles/password_spec.rb b/spec/features/profiles/password_spec.rb index 2c757f99a27..225d4c16841 100644 --- a/spec/features/profiles/password_spec.rb +++ b/spec/features/profiles/password_spec.rb @@ -53,12 +53,12 @@ describe 'Profile > Password' do context 'Regular user' do let(:user) { create(:user) } - it 'renders 404 when sign-in is disabled' do + it 'renders 200 when sign-in is disabled' do stub_application_setting(password_authentication_enabled: false) visit edit_profile_password_path - expect(page).to have_http_status(404) + expect(page).to have_http_status(200) end end diff --git a/spec/features/projects/guest_navigation_menu_spec.rb b/spec/features/projects/guest_navigation_menu_spec.rb index 2385e1d9333..98c7ef57a51 100644 --- a/spec/features/projects/guest_navigation_menu_spec.rb +++ b/spec/features/projects/guest_navigation_menu_spec.rb @@ -13,8 +13,8 @@ describe 'Guest navigation menu' do it 'shows allowed tabs only' do visit project_path(project) - within('.layout-nav') do - expect(page).to have_content 'Project' + within('.nav-sidebar') do + expect(page).to have_content 'Overview' expect(page).to have_content 'Issues' expect(page).to have_content 'Wiki' diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 2eb6fab129d..ad2db1a34f4 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -18,23 +18,25 @@ feature 'Import/Export - project import integration test', js: true do context 'when selecting the namespace' do let(:user) { create(:admin) } - let!(:namespace) { create(:namespace, name: "asd", owner: user) } + let!(:namespace) { create(:namespace, name: 'asd', owner: user) } + let(:project_path) { 'test-project-path' + SecureRandom.hex } context 'prefilled the path' do scenario 'user imports an exported project successfully' do visit new_project_path select2(namespace.id, from: '#project_namespace_id') - fill_in :project_path, with: 'test-project-path', visible: true + fill_in :project_path, with: project_path, visible: true click_link 'GitLab export' expect(page).to have_content('Import an exported GitLab project') - expect(URI.parse(current_url).query).to eq("namespace_id=#{namespace.id}&path=test-project-path") - expect(Gitlab::ImportExport).to receive(:import_upload_path).with(filename: /\A\h{32}_test-project-path\z/).and_call_original + expect(URI.parse(current_url).query).to eq("namespace_id=#{namespace.id}&path=#{project_path}") + expect(Gitlab::ImportExport).to receive(:import_upload_path).with(filename: /\A\h{32}_test-project-path\h*\z/).and_call_original attach_file('file', file) + click_on 'Import project' - expect { click_on 'Import project' }.to change { Project.count }.by(1) + expect(Project.count).to eq(1) project = Project.last expect(project).not_to be_nil @@ -64,7 +66,7 @@ feature 'Import/Export - project import integration test', js: true do end scenario 'invalid project' do - namespace = create(:namespace, name: "asd", owner: user) + namespace = create(:namespace, name: 'asdf', owner: user) project = create(:project, namespace: namespace) visit new_project_path diff --git a/spec/features/projects/issuable_counts_caching_spec.rb b/spec/features/projects/issuable_counts_caching_spec.rb deleted file mode 100644 index 1804d9dc244..00000000000 --- a/spec/features/projects/issuable_counts_caching_spec.rb +++ /dev/null @@ -1,132 +0,0 @@ -require 'spec_helper' - -describe 'Issuable counts caching', :use_clean_rails_memory_store_caching do - let!(:member) { create(:user) } - let!(:member_2) { create(:user) } - let!(:non_member) { create(:user) } - let!(:project) { create(:project, :public) } - let!(:open_issue) { create(:issue, project: project) } - let!(:confidential_issue) { create(:issue, :confidential, project: project, author: non_member) } - let!(:closed_issue) { create(:issue, :closed, project: project) } - - before do - project.add_developer(member) - project.add_developer(member_2) - end - - it 'caches issuable counts correctly for non-members' do - # We can't use expect_any_instance_of because that uses a single instance. - counts = 0 - - allow_any_instance_of(IssuesFinder).to receive(:count_by_state).and_wrap_original do |m, *args| - counts += 1 - - m.call(*args) - end - - aggregate_failures 'only counts once on first load with no params, and caches for later loads' do - expect { visit project_issues_path(project) } - .to change { counts }.by(1) - - expect { visit project_issues_path(project) } - .not_to change { counts } - end - - aggregate_failures 'uses counts from cache on load from non-member' do - sign_in(non_member) - - expect { visit project_issues_path(project) } - .not_to change { counts } - - sign_out(non_member) - end - - aggregate_failures 'does not use the same cache for a member' do - sign_in(member) - - expect { visit project_issues_path(project) } - .to change { counts }.by(1) - - sign_out(member) - end - - aggregate_failures 'uses the same cache for all members' do - sign_in(member_2) - - expect { visit project_issues_path(project) } - .not_to change { counts } - - sign_out(member_2) - end - - aggregate_failures 'shares caches when params are passed' do - expect { visit project_issues_path(project, author_username: non_member.username) } - .to change { counts }.by(1) - - sign_in(member) - - expect { visit project_issues_path(project, author_username: non_member.username) } - .to change { counts }.by(1) - - sign_in(non_member) - - expect { visit project_issues_path(project, author_username: non_member.username) } - .not_to change { counts } - - sign_in(member_2) - - expect { visit project_issues_path(project, author_username: non_member.username) } - .not_to change { counts } - - sign_out(member_2) - end - - aggregate_failures 'resets caches on issue close' do - Issues::CloseService.new(project, member).execute(open_issue) - - expect { visit project_issues_path(project) } - .to change { counts }.by(1) - - sign_in(member) - - expect { visit project_issues_path(project) } - .to change { counts }.by(1) - - sign_in(non_member) - - expect { visit project_issues_path(project) } - .not_to change { counts } - - sign_in(member_2) - - expect { visit project_issues_path(project) } - .not_to change { counts } - - sign_out(member_2) - end - - aggregate_failures 'does not reset caches on issue update' do - Issues::UpdateService.new(project, member, title: 'new title').execute(open_issue) - - expect { visit project_issues_path(project) } - .not_to change { counts } - - sign_in(member) - - expect { visit project_issues_path(project) } - .not_to change { counts } - - sign_in(non_member) - - expect { visit project_issues_path(project) } - .not_to change { counts } - - sign_in(member_2) - - expect { visit project_issues_path(project) } - .not_to change { counts } - - sign_out(member_2) - end - end -end diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index 3fa32e2d10b..9ed82f5a67f 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -292,26 +292,44 @@ feature 'Jobs' do end feature 'Variables' do - let(:trigger_request) { create(:ci_trigger_request_with_variables) } + let(:trigger_request) { create(:ci_trigger_request) } let(:job) do create :ci_build, pipeline: pipeline, trigger_request: trigger_request end - before do - visit project_job_path(project, job) + shared_examples 'expected variables behavior' do + it 'shows variable key and value after click', js: true do + expect(page).to have_css('.reveal-variables') + expect(page).not_to have_css('.js-build-variable') + expect(page).not_to have_css('.js-build-value') + + click_button 'Reveal Variables' + + expect(page).not_to have_css('.reveal-variables') + expect(page).to have_selector('.js-build-variable', text: 'TRIGGER_KEY_1') + expect(page).to have_selector('.js-build-value', text: 'TRIGGER_VALUE_1') + end end - it 'shows variable key and value after click', js: true do - expect(page).to have_css('.reveal-variables') - expect(page).not_to have_css('.js-build-variable') - expect(page).not_to have_css('.js-build-value') + context 'when variables are stored in trigger_request' do + before do + trigger_request.update_attribute(:variables, { 'TRIGGER_KEY_1' => 'TRIGGER_VALUE_1' } ) - click_button 'Reveal Variables' + visit project_job_path(project, job) + end + + it_behaves_like 'expected variables behavior' + end + + context 'when variables are stored in pipeline_variables' do + before do + create(:ci_pipeline_variable, pipeline: pipeline, key: 'TRIGGER_KEY_1', value: 'TRIGGER_VALUE_1') + + visit project_job_path(project, job) + end - expect(page).not_to have_css('.reveal-variables') - expect(page).to have_selector('.js-build-variable', text: 'TRIGGER_KEY_1') - expect(page).to have_selector('.js-build-value', text: 'TRIGGER_VALUE_1') + it_behaves_like 'expected variables behavior' end end diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb index 24c9f708456..0fbe1ddb2a5 100644 --- a/spec/features/projects/members/user_requests_access_spec.rb +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'Projects > Members > User requests access' do +feature 'Projects > Members > User requests access', :js do let(:user) { create(:user) } let(:project) { create(:project, :public, :access_requestable, :repository) } let(:master) { project.owner } @@ -46,11 +46,10 @@ feature 'Projects > Members > User requests access' do expect(project.requesters.exists?(user_id: user)).to be_truthy - page.within('.layout-nav .nav-links') do + page.within('.nav-sidebar') do click_link('Members') end - visit project_project_members_path(project) page.within('.content') do expect(page).not_to have_content(user.name) end diff --git a/spec/features/projects/project_settings_spec.rb b/spec/features/projects/project_settings_spec.rb index 80d91e5915f..5d77cd1ccd5 100644 --- a/spec/features/projects/project_settings_spec.rb +++ b/spec/features/projects/project_settings_spec.rb @@ -46,7 +46,7 @@ describe 'Edit Project Settings' do context 'when changing project name' do it 'renames the repository' do rename_project(project, name: 'bar') - expect(find('h1.title')).to have_content(project.name) + expect(find('.breadcrumbs')).to have_content(project.name) end context 'with emojis' do @@ -74,7 +74,7 @@ describe 'Edit Project Settings' do new_path = namespace_project_path(project.namespace, 'bar') visit new_path expect(current_path).to eq(new_path) - expect(find('h1.title')).to have_content(project.name) + expect(find('.breadcrumbs')).to have_content(project.name) end specify 'the project is accessible via a redirect from the old path' do @@ -83,7 +83,7 @@ describe 'Edit Project Settings' do new_path = namespace_project_path(project.namespace, 'bar') visit old_path expect(current_path).to eq(new_path) - expect(find('h1.title')).to have_content(project.name) + expect(find('.breadcrumbs')).to have_content(project.name) end context 'and a new project is added with the same path' do @@ -93,7 +93,7 @@ describe 'Edit Project Settings' do new_project = create(:project, namespace: user.namespace, path: 'gitlabhq', name: 'quz') visit old_path expect(current_path).to eq(old_path) - expect(find('h1.title')).to have_content(new_project.name) + expect(find('.breadcrumbs')).to have_content(new_project.name) end end end @@ -120,7 +120,7 @@ describe 'Edit Project Settings' do new_path = namespace_project_path(group, project) visit new_path expect(current_path).to eq(new_path) - expect(find('h1.title')).to have_content(project.name) + expect(find('.breadcrumbs')).to have_content(project.name) end specify 'the project is accessible via a redirect from the old path' do @@ -129,7 +129,7 @@ describe 'Edit Project Settings' do new_path = namespace_project_path(group, project) visit old_path expect(current_path).to eq(new_path) - expect(find('h1.title')).to have_content(project.name) + expect(find('.breadcrumbs')).to have_content(project.name) end context 'and a new project is added with the same path' do @@ -139,7 +139,7 @@ describe 'Edit Project Settings' do new_project = create(:project, namespace: user.namespace, path: 'gitlabhq', name: 'quz') visit old_path expect(current_path).to eq(old_path) - expect(find('h1.title')).to have_content(new_project.name) + expect(find('.breadcrumbs')).to have_content(new_project.name) end end end diff --git a/spec/features/projects/sub_group_issuables_spec.rb b/spec/features/projects/sub_group_issuables_spec.rb index aaf64d42515..b2b39dbd24c 100644 --- a/spec/features/projects/sub_group_issuables_spec.rb +++ b/spec/features/projects/sub_group_issuables_spec.rb @@ -24,7 +24,7 @@ describe 'Subgroup Issuables', :js, :nested_groups do end def expect_to_have_full_subgroup_title - title = find('.title-container') + title = find('.breadcrumbs-links') expect(title).not_to have_selector '.initializing' expect(title).to have_content 'group / subgroup / project' diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index baf3d29e6c5..81f7ab80a04 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -95,49 +95,6 @@ feature 'Project' do end end - describe 'project title' do - let(:user) { create(:user) } - let(:project) { create(:project, namespace: user.namespace) } - - before do - sign_in(user) - project.add_user(user, Gitlab::Access::MASTER) - visit project_path(project) - end - - it 'clicks toggle and shows dropdown', js: true do - find('.js-projects-dropdown-toggle').click - expect(page).to have_css('.dropdown-menu-projects .dropdown-content li', count: 1) - end - end - - describe 'project title' do - let(:user) { create(:user) } - let(:project) { create(:project, namespace: user.namespace) } - let(:project2) { create(:project, namespace: user.namespace, path: 'test') } - let(:issue) { create(:issue, project: project) } - - context 'on issues page', js: true do - before do - sign_in(user) - project.add_user(user, Gitlab::Access::MASTER) - project2.add_user(user, Gitlab::Access::MASTER) - visit project_issue_path(project, issue) - end - - it 'clicks toggle and shows dropdown' do - find('.js-projects-dropdown-toggle').click - expect(page).to have_css('.dropdown-menu-projects .dropdown-content li', count: 2) - - page.within '.dropdown-menu-projects' do - click_link project.name_with_namespace - end - - expect(page).to have_content project.name - end - end - end - describe 'tree view (default view is set to Files)' do let(:user) { create(:user, project_view: 'files') } let(:project) { create(:forked_project_with_submodules) } diff --git a/spec/features/reportable_note/commit_spec.rb b/spec/features/reportable_note/commit_spec.rb index 3bf25221e36..9b6864eb90f 100644 --- a/spec/features/reportable_note/commit_spec.rb +++ b/spec/features/reportable_note/commit_spec.rb @@ -18,7 +18,7 @@ describe 'Reportable note on commit', :js do visit project_commit_path(project, sample_commit.id) end - it_behaves_like 'reportable note' + it_behaves_like 'reportable note', 'commit' end context 'a diff note' do @@ -28,6 +28,6 @@ describe 'Reportable note on commit', :js do visit project_commit_path(project, sample_commit.id) end - it_behaves_like 'reportable note' + it_behaves_like 'reportable note', 'commit' end end diff --git a/spec/features/reportable_note/issue_spec.rb b/spec/features/reportable_note/issue_spec.rb index 21e96f6f103..f5a1950e48e 100644 --- a/spec/features/reportable_note/issue_spec.rb +++ b/spec/features/reportable_note/issue_spec.rb @@ -13,5 +13,5 @@ describe 'Reportable note on issue', :js do visit project_issue_path(project, issue) end - it_behaves_like 'reportable note' + it_behaves_like 'reportable note', 'issue' end diff --git a/spec/features/reportable_note/merge_request_spec.rb b/spec/features/reportable_note/merge_request_spec.rb index bb296546e06..1f69257f7ed 100644 --- a/spec/features/reportable_note/merge_request_spec.rb +++ b/spec/features/reportable_note/merge_request_spec.rb @@ -15,12 +15,12 @@ describe 'Reportable note on merge request', :js do context 'a normal note' do let!(:note) { create(:note_on_merge_request, noteable: merge_request, project: project) } - it_behaves_like 'reportable note' + it_behaves_like 'reportable note', 'merge_request' end context 'a diff note' do let!(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) } - it_behaves_like 'reportable note' + it_behaves_like 'reportable note', 'merge_request' end end diff --git a/spec/features/reportable_note/snippets_spec.rb b/spec/features/reportable_note/snippets_spec.rb index f1e48ed46be..98ef50b78de 100644 --- a/spec/features/reportable_note/snippets_spec.rb +++ b/spec/features/reportable_note/snippets_spec.rb @@ -17,6 +17,6 @@ describe 'Reportable note on snippets', :js do visit project_snippet_path(project, snippet) end - it_behaves_like 'reportable note' + it_behaves_like 'reportable note', 'snippet' end end diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb index 785cfeb34bd..c7f0e342809 100644 --- a/spec/features/runners_spec.rb +++ b/spec/features/runners_spec.rb @@ -43,6 +43,21 @@ feature 'Runners' do expect(page).not_to have_content(specific_runner.display_name) end + scenario 'user edits the runner to be protected' do + visit runners_path(project) + + within '.activated-specific-runners' do + first('.edit-runner > a').click + end + + expect(page.find_field('runner[access_level]')).not_to be_checked + + check 'runner_access_level' + click_button 'Save changes' + + expect(page).to have_content 'Protected Yes' + end + context 'when a runner has a tag' do background do specific_runner.update(tag_list: ['tag']) diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index 979e36e7e86..49f6ce91bbd 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -160,7 +160,7 @@ describe "Search" do fill_in 'search', with: 'gitlab' find('#search').native.send_keys(:enter) - page.within '.title' do + page.within '.breadcrumbs-sub-title' do expect(page).to have_content 'Search' end end diff --git a/spec/features/signed_commits_spec.rb b/spec/features/signed_commits_spec.rb new file mode 100644 index 00000000000..8efa5b58141 --- /dev/null +++ b/spec/features/signed_commits_spec.rb @@ -0,0 +1,179 @@ +require 'spec_helper' + +describe 'GPG signed commits', :js do + let(:project) { create(:project, :repository) } + + it 'changes from unverified to verified when the user changes his email to match the gpg key' do + user = create :user, email: 'unrelated.user@example.org' + project.team << [user, :master] + + Sidekiq::Testing.inline! do + create :gpg_key, key: GpgHelpers::User1.public_key, user: user + end + + sign_in(user) + + visit project_commits_path(project, :'signed-commits') + + within '#commits-list' do + expect(page).to have_content 'Unverified' + expect(page).not_to have_content 'Verified' + end + + # user changes his email which makes the gpg key verified + Sidekiq::Testing.inline! do + user.skip_reconfirmation! + user.update_attributes!(email: GpgHelpers::User1.emails.first) + end + + visit project_commits_path(project, :'signed-commits') + + within '#commits-list' do + expect(page).to have_content 'Unverified' + expect(page).to have_content 'Verified' + end + end + + it 'changes from unverified to verified when the user adds the missing gpg key' do + user = create :user, email: GpgHelpers::User1.emails.first + project.team << [user, :master] + + sign_in(user) + + visit project_commits_path(project, :'signed-commits') + + within '#commits-list' do + expect(page).to have_content 'Unverified' + expect(page).not_to have_content 'Verified' + end + + # user adds the gpg key which makes the signature valid + Sidekiq::Testing.inline! do + create :gpg_key, key: GpgHelpers::User1.public_key, user: user + end + + visit project_commits_path(project, :'signed-commits') + + within '#commits-list' do + expect(page).to have_content 'Unverified' + expect(page).to have_content 'Verified' + end + end + + context 'shows popover badges' do + let(:user_1) do + create :user, email: GpgHelpers::User1.emails.first, username: 'nannie.bernhard', name: 'Nannie Bernhard' + end + + let(:user_1_key) do + Sidekiq::Testing.inline! do + create :gpg_key, key: GpgHelpers::User1.public_key, user: user_1 + end + end + + let(:user_2) do + create(:user, email: GpgHelpers::User2.emails.first, username: 'bette.cartwright', name: 'Bette Cartwright').tap do |user| + # secondary, unverified email + create :email, user: user, email: GpgHelpers::User2.emails.last + end + end + + let(:user_2_key) do + Sidekiq::Testing.inline! do + create :gpg_key, key: GpgHelpers::User2.public_key, user: user_2 + end + end + + before do + user = create :user + project.team << [user, :master] + + sign_in(user) + end + + it 'unverified signature' do + visit project_commits_path(project, :'signed-commits') + + within(find('.commit', text: 'signed commit by bette cartwright')) do + click_on 'Unverified' + within '.popover' do + expect(page).to have_content 'This commit was signed with an unverified signature.' + expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}" + end + end + end + + it 'unverified signature: user email does not match the committer email, but is the same user' do + user_2_key + + visit project_commits_path(project, :'signed-commits') + + within(find('.commit', text: 'signed and authored commit by bette cartwright, different email')) do + click_on 'Unverified' + within '.popover' do + expect(page).to have_content 'This commit was signed with a verified signature, but the committer email is not verified to belong to the same user.' + expect(page).to have_content 'Bette Cartwright' + expect(page).to have_content '@bette.cartwright' + expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}" + end + end + end + + it 'unverified signature: user email does not match the committer email' do + user_2_key + + visit project_commits_path(project, :'signed-commits') + + within(find('.commit', text: 'signed commit by bette cartwright')) do + click_on 'Unverified' + within '.popover' do + expect(page).to have_content "This commit was signed with a different user's verified signature." + expect(page).to have_content 'Bette Cartwright' + expect(page).to have_content '@bette.cartwright' + expect(page).to have_content "GPG Key ID: #{GpgHelpers::User2.primary_keyid}" + end + end + end + + it 'verified and the gpg user has a gitlab profile' do + user_1_key + + visit project_commits_path(project, :'signed-commits') + + within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do + click_on 'Verified' + within '.popover' do + expect(page).to have_content 'This commit was signed with a verified signature and the committer email is verified to belong to the same user.' + expect(page).to have_content 'Nannie Bernhard' + expect(page).to have_content '@nannie.bernhard' + expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}" + end + end + end + + it "verified and the gpg user's profile doesn't exist anymore" do + user_1_key + + visit project_commits_path(project, :'signed-commits') + + # wait for the signature to get generated + within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do + expect(page).to have_content 'Verified' + end + + user_1.destroy! + + refresh + + within(find('.commit', text: 'signed and authored commit by nannie bernhard')) do + click_on 'Verified' + within '.popover' do + expect(page).to have_content 'This commit was signed with a verified signature and the committer email is verified to belong to the same user.' + expect(page).to have_content 'Nannie Bernhard' + expect(page).to have_content 'nannie.bernhard@example.com' + expect(page).to have_content "GPG Key ID: #{GpgHelpers::User1.primary_keyid}" + end + end + end + end +end diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb index 580258f77eb..aeb0534b733 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -181,7 +181,7 @@ feature 'Task Lists' do project: project, author: user) end - it 'renders for note body' do + it 'renders for note body', :js do visit_issue(project, issue) expect(page).to have_selector('.note ul.task-list', count: 1) @@ -189,21 +189,20 @@ feature 'Task Lists' do expect(page).to have_selector('.note ul input[checked]', count: 2) end - it 'contains the required selectors' do + it 'contains the required selectors', :js do visit_issue(project, issue) expect(page).to have_selector('.note .js-task-list-container') expect(page).to have_selector('.note .js-task-list-container .task-list .task-list-item .task-list-item-checkbox') - expect(page).to have_selector('.note .js-task-list-container .js-task-list-field') end - it 'is only editable by author' do + it 'is only editable by author', :js do visit_issue(project, issue) expect(page).to have_selector('.js-task-list-container') - logout(:user) + gitlab_sign_out - login_as(user2) + gitlab_sign_in(user2) visit current_path expect(page).not_to have_selector('.js-task-list-container') end @@ -215,7 +214,7 @@ feature 'Task Lists' do project: project, author: user) end - it 'renders for note body' do + it 'renders for note body', :js do visit_issue(project, issue) expect(page).to have_selector('.note ul.task-list', count: 1) @@ -230,7 +229,7 @@ feature 'Task Lists' do project: project, author: user) end - it 'renders for note body' do + it 'renders for note body', :js do visit_issue(project, issue) expect(page).to have_selector('.note ul.task-list', count: 1) diff --git a/spec/features/uploads/user_uploads_file_to_note_spec.rb b/spec/features/uploads/user_uploads_file_to_note_spec.rb index 53cad623a35..e1c95590af1 100644 --- a/spec/features/uploads/user_uploads_file_to_note_spec.rb +++ b/spec/features/uploads/user_uploads_file_to_note_spec.rb @@ -10,6 +10,7 @@ feature 'User uploads file to note' do before do sign_in(user) visit project_issue_path(project, issue) + wait_for_requests end context 'before uploading' do diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index 0e80df94e18..47b173dea0a 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -15,8 +15,8 @@ describe IssuesFinder do set(:award_emoji3) { create(:award_emoji, name: 'thumbsdown', user: user, awardable: issue3) } describe '#execute' do - set(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') } - set(:label_link) { create(:label_link, label: label, target: issue2) } + let!(:closed_issue) { create(:issue, author: user2, assignees: [user2], project: project2, state: 'closed') } + let!(:label_link) { create(:label_link, label: label, target: issue2) } let(:search_user) { user } let(:params) { {} } let(:issues) { described_class.new(search_user, params.reverse_merge(scope: scope, state: 'opened')).execute } @@ -347,6 +347,20 @@ describe IssuesFinder do end end + describe '#row_count', :request_store do + it 'returns the number of rows for the default state' do + finder = described_class.new(user) + + expect(finder.row_count).to eq(3) + end + + it 'returns the number of rows for a given state' do + finder = described_class.new(user, state: 'closed') + + expect(finder.row_count).to be_zero + end + end + describe '#with_confidentiality_access_check' do let(:guest) { create(:user) } set(:authorized_user) { create(:user) } diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index b54155a6704..95f445e7905 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -108,4 +108,18 @@ describe MergeRequestsFinder do end end end + + describe '#row_count', :request_store do + it 'returns the number of rows for the default state' do + finder = described_class.new(user) + + expect(finder.row_count).to eq(3) + end + + it 'returns the number of rows for a given state' do + finder = described_class.new(user, state: 'closed') + + expect(finder.row_count).to eq(1) + end + end end diff --git a/spec/fixtures/api/schemas/entities/merge_request.json b/spec/fixtures/api/schemas/entities/merge_request.json index 2f12b671dec..1030f323a1f 100644 --- a/spec/fixtures/api/schemas/entities/merge_request.json +++ b/spec/fixtures/api/schemas/entities/merge_request.json @@ -18,6 +18,8 @@ "total_time_spent": { "type": "integer" }, "human_time_estimate": { "type": ["integer", "null"] }, "human_total_time_spent": { "type": ["integer", "null"] }, + "milestone": { "type": ["object", "null"] }, + "labels": { "type": ["array", "null"] }, "in_progress_merge_commit_sha": { "type": ["string", "null"] }, "merge_error": { "type": ["string", "null"] }, "merge_commit_sha": { "type": ["string", "null"] }, diff --git a/spec/fixtures/api/schemas/pipeline_schedule.json b/spec/fixtures/api/schemas/pipeline_schedule.json index f6346bd0fb6..c76c6945117 100644 --- a/spec/fixtures/api/schemas/pipeline_schedule.json +++ b/spec/fixtures/api/schemas/pipeline_schedule.json @@ -31,6 +31,10 @@ "web_url": { "type": "uri" } }, "additionalProperties": false + }, + "variables": { + "type": ["array", "null"], + "items": { "$ref": "pipeline_schedule_variable.json" } } }, "required": [ diff --git a/spec/fixtures/api/schemas/pipeline_schedule_variable.json b/spec/fixtures/api/schemas/pipeline_schedule_variable.json new file mode 100644 index 00000000000..f7ccb2d44a0 --- /dev/null +++ b/spec/fixtures/api/schemas/pipeline_schedule_variable.json @@ -0,0 +1,8 @@ +{ + "type": ["object", "null"], + "properties": { + "key": { "type": "string" }, + "value": { "type": "string" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/fuzzy.po b/spec/fixtures/fuzzy.po new file mode 100644 index 00000000000..99b7d12b91a --- /dev/null +++ b/spec/fixtures/fuzzy.po @@ -0,0 +1,27 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-07-12 12:35-0500\n" +"Language-Team: Spanish\n" +"Language: es\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" +"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n" +"X-Generator: Poedit 2.0.2\n" + +msgid "1 commit" +msgid_plural "%d commits" +msgstr[0] "1 cambio" +msgstr[1] "%d cambios" + +#, fuzzy +msgid "PipelineSchedules|Remove variable row" +msgstr "Схема" diff --git a/spec/fixtures/invalid.po b/spec/fixtures/invalid.po new file mode 100644 index 00000000000..039a56e9fc0 --- /dev/null +++ b/spec/fixtures/invalid.po @@ -0,0 +1,25 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-07-12 12:35-0500\n" +"Language-Team: Spanish\n" +"Language: es\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" +"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n" +"X-Generator: Poedit 2.0.2\n" + +msgid "%d commit" +msgid_plural "%d commits" +msgstr[0] "%d cambio" +msgstr[1] "%d cambios" + +But this doesn't even look like an PO-entry
\ No newline at end of file diff --git a/spec/fixtures/missing_metadata.po b/spec/fixtures/missing_metadata.po new file mode 100644 index 00000000000..b1999c933f1 --- /dev/null +++ b/spec/fixtures/missing_metadata.po @@ -0,0 +1,4 @@ +msgid "1 commit" +msgid_plural "%d commits" +msgstr[0] "1 cambio" +msgstr[1] "%d cambios" diff --git a/spec/fixtures/missing_plurals.po b/spec/fixtures/missing_plurals.po new file mode 100644 index 00000000000..09ca0c82718 --- /dev/null +++ b/spec/fixtures/missing_plurals.po @@ -0,0 +1,22 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-07-13 12:10-0500\n" +"Language-Team: Spanish\n" +"Language: es\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" +"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n" +"X-Generator: Poedit 2.0.2\n" + +msgid "%d commit" +msgid_plural "%d commits" +msgstr[0] "%d cambio" diff --git a/spec/fixtures/multiple_plurals.po b/spec/fixtures/multiple_plurals.po new file mode 100644 index 00000000000..84b17b13ffa --- /dev/null +++ b/spec/fixtures/multiple_plurals.po @@ -0,0 +1,26 @@ +# Arthur Charron <arthur.charron@hotmail.fr>, 2017. #zanata +# Huang Tao <htve@outlook.com>, 2017. #zanata +# Kohei Ota <inductor@kela.jp>, 2017. #zanata +# Taisuke Inoue <taisuke.inoue.jp@gmail.com>, 2017. #zanata +# Takuya Noguchi <takninnovationresearch@gmail.com>, 2017. #zanata +# YANO Tethurou <tetuyano+zana@gmail.com>, 2017. #zanata +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"PO-Revision-Date: 2017-08-06 11:23-0400\n" +"Last-Translator: Taisuke Inoue <taisuke.inoue.jp@gmail.com>\n" +"Language-Team: Japanese \"Language-Team: Russian (https://translate.zanata.org/" +"project/view/GitLab)\n" +"Language: ja\n" +"X-Generator: Zanata 3.9.6\n" +"Plural-Forms: nplurals=3; plural=n\n" + +msgid "%d commit" +msgid_plural "%d commits" +msgstr[0] "%d個のコミット" +msgstr[1] "%d個のコミット" +msgstr[2] "missing a variable" diff --git a/spec/fixtures/newlines.po b/spec/fixtures/newlines.po new file mode 100644 index 00000000000..f5bc86f39a7 --- /dev/null +++ b/spec/fixtures/newlines.po @@ -0,0 +1,48 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-07-12 12:35-0500\n" +"Language-Team: Spanish\n" +"Language: es\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" +"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n" +"X-Generator: Poedit 2.0.2\n" + +msgid "1 commit" +msgid_plural "%d commits" +msgstr[0] "1 cambio" +msgstr[1] "%d cambios" + +msgid "" +"You are going to remove %{group_name}.\n" +"Removed groups CANNOT be restored!\n" +"Are you ABSOLUTELY sure?" +msgstr "" +"Va a eliminar %{group_name}.\n" +"¡El grupo eliminado NO puede ser restaurado!\n" +"¿Estás TOTALMENTE seguro?" + +msgid "With plural" +msgid_plural "with plurals" +msgstr[0] "first" +msgstr[1] "second" +msgstr[2] "" +"with" +"multiple" +"lines" + +msgid "multiline plural id" +msgid_plural "" +"Plural" +"Id" +msgstr[0] "first" +msgstr[1] "second" diff --git a/spec/fixtures/unescaped_chars.po b/spec/fixtures/unescaped_chars.po new file mode 100644 index 00000000000..fbafe523fb3 --- /dev/null +++ b/spec/fixtures/unescaped_chars.po @@ -0,0 +1,21 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-07-13 12:10-0500\n" +"Language-Team: Spanish\n" +"Language: es\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" +"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n" +"X-Generator: Poedit 2.0.2\n" + +msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?" +msgstr "將要把 %{project_name_with_namespace} 的所有權轉移給另一個人。真的「100%確定」要這麼做嗎?" diff --git a/spec/fixtures/valid.po b/spec/fixtures/valid.po new file mode 100644 index 00000000000..e43fd5fea15 --- /dev/null +++ b/spec/fixtures/valid.po @@ -0,0 +1,1136 @@ +# Spanish translations for gitlab package. +# Copyright (C) 2017 THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the gitlab package. +# FIRST AUTHOR <EMAIL@ADDRESS>, 2017. +# +msgid "" +msgstr "" +"Project-Id-Version: gitlab 1.0.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2017-07-13 12:10-0500\n" +"Language-Team: Spanish\n" +"Language: es\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" +"Last-Translator: Bob Van Landuyt <bob@gitlab.com>\n" +"X-Generator: Poedit 2.0.2\n" + +msgid "%d commit" +msgid_plural "%d commits" +msgstr[0] "%d cambio" +msgstr[1] "%d cambios" + +msgid "%s additional commit has been omitted to prevent performance issues." +msgid_plural "%s additional commits have been omitted to prevent performance issues." +msgstr[0] "%s cambio adicional ha sido omitido para evitar problemas de rendimiento." +msgstr[1] "%s cambios adicionales han sido omitidos para evitar problemas de rendimiento." + +msgid "%{commit_author_link} committed %{commit_timeago}" +msgstr "%{commit_author_link} cambió %{commit_timeago}" + +msgid "1 pipeline" +msgid_plural "%d pipelines" +msgstr[0] "1 pipeline" +msgstr[1] "%d pipelines" + +msgid "A collection of graphs regarding Continuous Integration" +msgstr "Una colección de gráficos sobre Integración Continua" + +msgid "About auto deploy" +msgstr "Acerca del auto despliegue" + +msgid "Active" +msgstr "Activo" + +msgid "Activity" +msgstr "Actividad" + +msgid "Add Changelog" +msgstr "Agregar Changelog" + +msgid "Add Contribution guide" +msgstr "Agregar guía de contribución" + +msgid "Add License" +msgstr "Agregar Licencia" + +msgid "Add an SSH key to your profile to pull or push via SSH." +msgstr "Agregar una clave SSH a tu perfil para actualizar o enviar a través de SSH." + +msgid "Add new directory" +msgstr "Agregar nuevo directorio" + +msgid "Archived project! Repository is read-only" +msgstr "¡Proyecto archivado! El repositorio es de solo lectura" + +msgid "Are you sure you want to delete this pipeline schedule?" +msgstr "¿Estás seguro que deseas eliminar esta programación del pipeline?" + +msgid "Attach a file by drag & drop or %{upload_link}" +msgstr "Adjunte un archivo arrastrando & soltando o %{upload_link}" + +msgid "Branch" +msgid_plural "Branches" +msgstr[0] "Rama" +msgstr[1] "Ramas" + +msgid "Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}" +msgstr "La rama <strong>%{branch_name}</strong> fue creada. Para configurar el auto despliegue, escoge una plantilla Yaml para GitLab CI y envía tus cambios. %{link_to_autodeploy_doc}" + +msgid "BranchSwitcherPlaceholder|Search branches" +msgstr "Buscar ramas" + +msgid "BranchSwitcherTitle|Switch branch" +msgstr "Cambiar rama" + +msgid "Branches" +msgstr "Ramas" + +msgid "Browse Directory" +msgstr "Examinar directorio" + +msgid "Browse File" +msgstr "Examinar archivo" + +msgid "Browse Files" +msgstr "Examinar archivos" + +msgid "Browse files" +msgstr "Examinar archivos" + +msgid "ByAuthor|by" +msgstr "por" + +msgid "CI configuration" +msgstr "Configuración de CI" + +msgid "Cancel" +msgstr "Cancelar" + +msgid "ChangeTypeActionLabel|Pick into branch" +msgstr "Escoger en la rama" + +msgid "ChangeTypeActionLabel|Revert in branch" +msgstr "Revertir en la rama" + +msgid "ChangeTypeAction|Cherry-pick" +msgstr "Cherry-pick" + +msgid "ChangeTypeAction|Revert" +msgstr "Revertir" + +msgid "Changelog" +msgstr "Changelog" + +msgid "Charts" +msgstr "Gráficos" + +msgid "Cherry-pick this commit" +msgstr "Escoger este cambio" + +msgid "Cherry-pick this merge request" +msgstr "Escoger esta solicitud de fusión" + +msgid "CiStatusLabel|canceled" +msgstr "cancelado" + +msgid "CiStatusLabel|created" +msgstr "creado" + +msgid "CiStatusLabel|failed" +msgstr "fallido" + +msgid "CiStatusLabel|manual action" +msgstr "acción manual" + +msgid "CiStatusLabel|passed" +msgstr "pasó" + +msgid "CiStatusLabel|passed with warnings" +msgstr "pasó con advertencias" + +msgid "CiStatusLabel|pending" +msgstr "pendiente" + +msgid "CiStatusLabel|skipped" +msgstr "omitido" + +msgid "CiStatusLabel|waiting for manual action" +msgstr "esperando acción manual" + +msgid "CiStatusText|blocked" +msgstr "bloqueado" + +msgid "CiStatusText|canceled" +msgstr "cancelado" + +msgid "CiStatusText|created" +msgstr "creado" + +msgid "CiStatusText|failed" +msgstr "fallado" + +msgid "CiStatusText|manual" +msgstr "manual" + +msgid "CiStatusText|passed" +msgstr "pasó" + +msgid "CiStatusText|pending" +msgstr "pendiente" + +msgid "CiStatusText|skipped" +msgstr "omitido" + +msgid "CiStatus|running" +msgstr "en ejecución" + +msgid "Commit" +msgid_plural "Commits" +msgstr[0] "Cambio" +msgstr[1] "Cambios" + +msgid "Commit duration in minutes for last 30 commits" +msgstr "Duración de los cambios en minutos para los últimos 30" + +msgid "Commit message" +msgstr "Mensaje del cambio" + +msgid "CommitBoxTitle|Commit" +msgstr "Cambio" + +msgid "CommitMessage|Add %{file_name}" +msgstr "Agregar %{file_name}" + +msgid "Commits" +msgstr "Cambios" + +msgid "Commits feed" +msgstr "Feed de cambios" + +msgid "Commits|History" +msgstr "Historial" + +msgid "Committed by" +msgstr "Enviado por" + +msgid "Compare" +msgstr "Comparar" + +msgid "Contribution guide" +msgstr "Guía de contribución" + +msgid "Contributors" +msgstr "Contribuidores" + +msgid "Copy URL to clipboard" +msgstr "Copiar URL al portapapeles" + +msgid "Copy commit SHA to clipboard" +msgstr "Copiar SHA del cambio al portapapeles" + +msgid "Create New Directory" +msgstr "Crear Nuevo Directorio" + +msgid "Create a personal access token on your account to pull or push via %{protocol}." +msgstr "Crear un token de acceso personal en tu cuenta para actualizar o enviar a través de %{protocol}." + +msgid "Create directory" +msgstr "Crear directorio" + +msgid "Create empty bare repository" +msgstr "Crear repositorio vacío" + +msgid "Create merge request" +msgstr "Crear solicitud de fusión" + +msgid "Create new..." +msgstr "Crear nuevo..." + +msgid "CreateNewFork|Fork" +msgstr "Bifurcar" + +msgid "CreateTag|Tag" +msgstr "Etiqueta" + +msgid "CreateTokenToCloneLink|create a personal access token" +msgstr "crear un token de acceso personal" + +msgid "Cron Timezone" +msgstr "Zona horaria del Cron" + +msgid "Cron syntax" +msgstr "Sintaxis de Cron" + +msgid "Custom notification events" +msgstr "Eventos de notificaciones personalizadas" + +msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notification_link}." +msgstr "Los niveles de notificación personalizados son los mismos que los niveles participantes. Con los niveles de notificación personalizados, también recibirá notificaciones para eventos seleccionados. Para obtener más información, consulte %{notification_link}." + +msgid "Cycle Analytics" +msgstr "Cycle Analytics" + +msgid "Cycle Analytics gives an overview of how much time it takes to go from idea to production in your project." +msgstr "Cycle Analytics ofrece una visión general de cuánto tiempo tarda en pasar de idea a producción en su proyecto." + +msgid "CycleAnalyticsStage|Code" +msgstr "Código" + +msgid "CycleAnalyticsStage|Issue" +msgstr "Incidencia" + +msgid "CycleAnalyticsStage|Plan" +msgstr "Planificación" + +msgid "CycleAnalyticsStage|Production" +msgstr "Producción" + +msgid "CycleAnalyticsStage|Review" +msgstr "Revisión" + +msgid "CycleAnalyticsStage|Staging" +msgstr "Puesta en escena" + +msgid "CycleAnalyticsStage|Test" +msgstr "Pruebas" + +msgid "Define a custom pattern with cron syntax" +msgstr "Definir un patrón personalizado con la sintaxis de cron" + +msgid "Delete" +msgstr "Eliminar" + +msgid "Deploy" +msgid_plural "Deploys" +msgstr[0] "Despliegue" +msgstr[1] "Despliegues" + +msgid "Description" +msgstr "Descripción" + +msgid "Directory name" +msgstr "Nombre del directorio" + +msgid "Don't show again" +msgstr "No mostrar de nuevo" + +msgid "Download" +msgstr "Descargar" + +msgid "Download tar" +msgstr "Descargar tar" + +msgid "Download tar.bz2" +msgstr "Descargar tar.bz2" + +msgid "Download tar.gz" +msgstr "Descargar tar.gz" + +msgid "Download zip" +msgstr "Descargar zip" + +msgid "DownloadArtifacts|Download" +msgstr "Descargar" + +msgid "DownloadCommit|Email Patches" +msgstr "Parches por correo electrónico" + +msgid "DownloadCommit|Plain Diff" +msgstr "Diferencias en texto plano" + +msgid "DownloadSource|Download" +msgstr "Descargar" + +msgid "Edit" +msgstr "Editar" + +msgid "Edit Pipeline Schedule %{id}" +msgstr "Editar Programación del Pipeline %{id}" + +msgid "Every day (at 4:00am)" +msgstr "Todos los días (a las 4:00 am)" + +msgid "Every month (on the 1st at 4:00am)" +msgstr "Todos los meses (el día 1 a las 4:00 am)" + +msgid "Every week (Sundays at 4:00am)" +msgstr "Todas las semanas (domingos a las 4:00 am)" + +msgid "Failed to change the owner" +msgstr "Error al cambiar el propietario" + +msgid "Failed to remove the pipeline schedule" +msgstr "Error al eliminar la programación del pipeline" + +msgid "Files" +msgstr "Archivos" + +msgid "Filter by commit message" +msgstr "Filtrar por mensaje del cambio" + +msgid "Find by path" +msgstr "Buscar por ruta" + +msgid "Find file" +msgstr "Buscar archivo" + +msgid "FirstPushedBy|First" +msgstr "Primer" + +msgid "FirstPushedBy|pushed by" +msgstr "enviado por" + +msgid "Fork" +msgid_plural "Forks" +msgstr[0] "Bifurcación" +msgstr[1] "Bifurcaciones" + +msgid "ForkedFromProjectPath|Forked from" +msgstr "Bifurcado de" + +msgid "From issue creation until deploy to production" +msgstr "Desde la creación de la incidencia hasta el despliegue a producción" + +msgid "From merge request merge until deploy to production" +msgstr "Desde la integración de la solicitud de fusión hasta el despliegue a producción" + +msgid "Go to your fork" +msgstr "Ir a tu bifurcación" + +msgid "GoToYourFork|Fork" +msgstr "Bifurcación" + +msgid "Home" +msgstr "Inicio" + +msgid "Housekeeping successfully started" +msgstr "Servicio de limpieza iniciado con éxito" + +msgid "Import repository" +msgstr "Importar repositorio" + +msgid "Interval Pattern" +msgstr "Patrón de intervalo" + +msgid "Introducing Cycle Analytics" +msgstr "Introducción a Cycle Analytics" + +msgid "Jobs for last month" +msgstr "Trabajos del mes pasado" + +msgid "Jobs for last week" +msgstr "Trabajos de la semana pasada" + +msgid "Jobs for last year" +msgstr "Trabajos del año pasado" + +msgid "LFSStatus|Disabled" +msgstr "Deshabilitado" + +msgid "LFSStatus|Enabled" +msgstr "Habilitado" + +msgid "Last %d day" +msgid_plural "Last %d days" +msgstr[0] "Último %d día" +msgstr[1] "Últimos %d días" + +msgid "Last Pipeline" +msgstr "Último Pipeline" + +msgid "Last Update" +msgstr "Última actualización" + +msgid "Last commit" +msgstr "Último cambio" + +msgid "Learn more in the" +msgstr "Más información en la" + +msgid "Learn more in the|pipeline schedules documentation" +msgstr "documentación sobre la programación de pipelines" + +msgid "Leave group" +msgstr "Abandonar grupo" + +msgid "Leave project" +msgstr "Abandonar proyecto" + +msgid "Limited to showing %d event at most" +msgid_plural "Limited to showing %d events at most" +msgstr[0] "Limitado a mostrar máximo %d evento" +msgstr[1] "Limitado a mostrar máximo %d eventos" + +msgid "Median" +msgstr "Mediana" + +msgid "MissingSSHKeyWarningLink|add an SSH key" +msgstr "agregar una clave SSH" + +msgid "New Issue" +msgid_plural "New Issues" +msgstr[0] "Nueva incidencia" +msgstr[1] "Nuevas incidencias" + +msgid "New Pipeline Schedule" +msgstr "Nueva Programación del Pipeline" + +msgid "New branch" +msgstr "Nueva rama" + +msgid "New directory" +msgstr "Nuevo directorio" + +msgid "New file" +msgstr "Nuevo archivo" + +msgid "New issue" +msgstr "Nueva incidencia" + +msgid "New merge request" +msgstr "Nueva solicitud de fusión" + +msgid "New schedule" +msgstr "Nueva programación" + +msgid "New snippet" +msgstr "Nuevo fragmento de código" + +msgid "New tag" +msgstr "Nueva etiqueta" + +msgid "No repository" +msgstr "No hay repositorio" + +msgid "No schedules" +msgstr "No hay programaciones" + +msgid "Not available" +msgstr "No disponible" + +msgid "Not enough data" +msgstr "No hay suficientes datos" + +msgid "Notification events" +msgstr "Eventos de notificación" + +msgid "NotificationEvent|Close issue" +msgstr "Cerrar incidencia" + +msgid "NotificationEvent|Close merge request" +msgstr "Cerrar solicitud de fusión" + +msgid "NotificationEvent|Failed pipeline" +msgstr "Pipeline fallido" + +msgid "NotificationEvent|Merge merge request" +msgstr "Integrar solicitud de fusión" + +msgid "NotificationEvent|New issue" +msgstr "Nueva incidencia" + +msgid "NotificationEvent|New merge request" +msgstr "Nueva solicitud de fusión" + +msgid "NotificationEvent|New note" +msgstr "Nueva nota" + +msgid "NotificationEvent|Reassign issue" +msgstr "Reasignar incidencia" + +msgid "NotificationEvent|Reassign merge request" +msgstr "Reasignar solicitud de fusión" + +msgid "NotificationEvent|Reopen issue" +msgstr "Reabrir incidencia" + +msgid "NotificationEvent|Successful pipeline" +msgstr "Pipeline exitoso" + +msgid "NotificationLevel|Custom" +msgstr "Personalizado" + +msgid "NotificationLevel|Disabled" +msgstr "Deshabilitado" + +msgid "NotificationLevel|Global" +msgstr "Global" + +msgid "NotificationLevel|On mention" +msgstr "Cuando me mencionan" + +msgid "NotificationLevel|Participate" +msgstr "Participación" + +msgid "NotificationLevel|Watch" +msgstr "Vigilancia" + +msgid "OfSearchInADropdown|Filter" +msgstr "Filtrar" + +msgid "OpenedNDaysAgo|Opened" +msgstr "Abierto" + +msgid "Options" +msgstr "Opciones" + +msgid "Owner" +msgstr "Propietario" + +msgid "Pipeline" +msgstr "Pipeline" + +msgid "Pipeline Health" +msgstr "Estado del Pipeline" + +msgid "Pipeline Schedule" +msgstr "Programación del Pipeline" + +msgid "Pipeline Schedules" +msgstr "Programaciones de los Pipelines" + +msgid "PipelineCharts|Failed:" +msgstr "Fallidos:" + +msgid "PipelineCharts|Overall statistics" +msgstr "Estadísticas generales" + +msgid "PipelineCharts|Success ratio:" +msgstr "Ratio de éxito" + +msgid "PipelineCharts|Successful:" +msgstr "Exitosos:" + +msgid "PipelineCharts|Total:" +msgstr "Total:" + +msgid "PipelineSchedules|Activated" +msgstr "Activado" + +msgid "PipelineSchedules|Active" +msgstr "Activos" + +msgid "PipelineSchedules|All" +msgstr "Todos" + +msgid "PipelineSchedules|Inactive" +msgstr "Inactivos" + +msgid "PipelineSchedules|Input variable key" +msgstr "Ingrese nombre de clave" + +msgid "PipelineSchedules|Input variable value" +msgstr "Ingrese el valor de la variable" + +msgid "PipelineSchedules|Next Run" +msgstr "Próxima Ejecución" + +msgid "PipelineSchedules|None" +msgstr "Ninguno" + +msgid "PipelineSchedules|Provide a short description for this pipeline" +msgstr "Proporcione una descripción breve para este pipeline" + +msgid "PipelineSchedules|Remove variable row" +msgstr "Eliminar fila de variable" + +msgid "PipelineSchedules|Take ownership" +msgstr "Tomar posesión" + +msgid "PipelineSchedules|Target" +msgstr "Destino" + +msgid "PipelineSchedules|Variables" +msgstr "Variables" + +msgid "PipelineSheduleIntervalPattern|Custom" +msgstr "Personalizado" + +msgid "Pipelines" +msgstr "Pipelines" + +msgid "Pipelines charts" +msgstr "Gráficos de los pipelines" + +msgid "Pipeline|all" +msgstr "todos" + +msgid "Pipeline|success" +msgstr "exitósos" + +msgid "Pipeline|with stage" +msgstr "con etapa" + +msgid "Pipeline|with stages" +msgstr "con etapas" + +msgid "Project '%{project_name}' queued for deletion." +msgstr "Proyecto ‘%{project_name}’ en cola para eliminación." + +msgid "Project '%{project_name}' was successfully created." +msgstr "Proyecto ‘%{project_name}’ fue creado satisfactoriamente." + +msgid "Project '%{project_name}' was successfully updated." +msgstr "Proyecto ‘%{project_name}’ fue actualizado satisfactoriamente." + +msgid "Project '%{project_name}' will be deleted." +msgstr "Proyecto ‘%{project_name}’ será eliminado." + +msgid "Project access must be granted explicitly to each user." +msgstr "El acceso al proyecto debe concederse explícitamente a cada usuario." + +msgid "Project export could not be deleted." +msgstr "No se pudo eliminar la exportación del proyecto." + +msgid "Project export has been deleted." +msgstr "La exportación del proyecto ha sido eliminada." + +msgid "Project export link has expired. Please generate a new export from your project settings." +msgstr "El enlace de exportación del proyecto ha caducado. Por favor, genera una nueva exportación desde la configuración del proyecto." + +msgid "Project export started. A download link will be sent by email." +msgstr "Se inició la exportación del proyecto. Se enviará un enlace de descarga por correo electrónico." + +msgid "Project home" +msgstr "Inicio del proyecto" + +msgid "ProjectFeature|Disabled" +msgstr "Deshabilitada" + +msgid "ProjectFeature|Everyone with access" +msgstr "Todos con acceso" + +msgid "ProjectFeature|Only team members" +msgstr "Solo miembros del equipo" + +msgid "ProjectFileTree|Name" +msgstr "Nombre" + +msgid "ProjectLastActivity|Never" +msgstr "Nunca" + +msgid "ProjectLifecycle|Stage" +msgstr "Etapa" + +msgid "ProjectNetworkGraph|Graph" +msgstr "Historial gráfico" + +msgid "Read more" +msgstr "Leer más" + +msgid "Readme" +msgstr "Léeme" + +msgid "RefSwitcher|Branches" +msgstr "Ramas" + +msgid "RefSwitcher|Tags" +msgstr "Etiquetas" + +msgid "Related Commits" +msgstr "Cambios Relacionados" + +msgid "Related Deployed Jobs" +msgstr "Trabajos Desplegados Relacionados" + +msgid "Related Issues" +msgstr "Incidencias Relacionadas" + +msgid "Related Jobs" +msgstr "Trabajos Relacionados" + +msgid "Related Merge Requests" +msgstr "Solicitudes de fusión Relacionadas" + +msgid "Related Merged Requests" +msgstr "Solicitudes de fusión Relacionadas" + +msgid "Remind later" +msgstr "Recordar después" + +msgid "Remove project" +msgstr "Eliminar proyecto" + +msgid "Request Access" +msgstr "Solicitar acceso" + +msgid "Revert this commit" +msgstr "Revertir este cambio" + +msgid "Revert this merge request" +msgstr "Revertir esta solicitud de fusión" + +msgid "Save pipeline schedule" +msgstr "Guardar programación del pipeline" + +msgid "Schedule a new pipeline" +msgstr "Programar un nuevo pipeline" + +msgid "Scheduling Pipelines" +msgstr "Programación de Pipelines" + +msgid "Search branches and tags" +msgstr "Buscar ramas y etiquetas" + +msgid "Select Archive Format" +msgstr "Seleccionar formato de archivo" + +msgid "Select a timezone" +msgstr "Selecciona una zona horaria" + +msgid "Select target branch" +msgstr "Selecciona una rama de destino" + +msgid "Set a password on your account to pull or push via %{protocol}." +msgstr "Establezca una contraseña en su cuenta para actualizar o enviar a través de %{protocol}." + +msgid "Set up CI" +msgstr "Configurar CI" + +msgid "Set up Koding" +msgstr "Configurar Koding" + +msgid "Set up auto deploy" +msgstr "Configurar auto despliegue" + +msgid "SetPasswordToCloneLink|set a password" +msgstr "establecer una contraseña" + +msgid "Showing %d event" +msgid_plural "Showing %d events" +msgstr[0] "Mostrando %d evento" +msgstr[1] "Mostrando %d eventos" + +msgid "Source code" +msgstr "Código fuente" + +msgid "StarProject|Star" +msgstr "Destacar" + +msgid "Start a %{new_merge_request} with these changes" +msgstr "Iniciar una %{new_merge_request} con estos cambios" + +msgid "Switch branch/tag" +msgstr "Cambiar rama/etiqueta" + +msgid "Tag" +msgid_plural "Tags" +msgstr[0] "Etiqueta" +msgstr[1] "Etiquetas" + +msgid "Tags" +msgstr "Etiquetas" + +msgid "Target Branch" +msgstr "Rama de destino" + +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 "La etapa de desarrollo muestra el tiempo desde el primer cambio hasta la creación de la solicitud de fusión. Los datos serán automáticamente incorporados aquí una vez creada tu primera solicitud de fusión." + +msgid "The collection of events added to the data gathered for that stage." +msgstr "La colección de eventos agregados a los datos recopilados para esa etapa." + +msgid "The fork relationship has been removed." +msgstr "La relación con la bifurcación se ha eliminado." + +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 "La etapa de incidencia muestra el tiempo que toma desde la creación de un tema hasta asignar el tema a un hito, o añadir el tema a una lista en el panel de temas. Empieza a crear temas para ver los datos de esta etapa." + +msgid "The phase of the development lifecycle." +msgstr "La etapa del ciclo de vida de desarrollo." + +msgid "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." +msgstr "La programación de pipelines ejecuta pipelines en el futuro, repetidamente, para ramas o etiquetas específicas. Los pipelines programados heredarán acceso limitado al proyecto basado en su usuario asociado." + +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 "La etapa de planificación muestra el tiempo desde el paso anterior hasta el envío de tu primera confirmación. Este tiempo se añadirá automáticamente una vez que usted envíe el primer cambio." + +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 "La etapa de producción muestra el tiempo total que tarda entre la creación de una incidencia y el despliegue del código a producción. Los datos se añadirán automáticamente una vez haya finalizado por completo el ciclo de idea a producción." + +msgid "The project can be accessed by any logged in user." +msgstr "El proyecto puede ser accedido por cualquier usuario conectado." + +msgid "The project can be accessed without any authentication." +msgstr "El proyecto puede accederse sin ninguna autenticación." + +msgid "The repository for this project does not exist." +msgstr "El repositorio para este proyecto no existe." + +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 "La etapa de revisión muestra el tiempo desde la creación de la solicitud de fusión hasta que los cambios se fusionaron. Los datos se añadirán automáticamente después de fusionar su primera solicitud de fusión." + +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 "La etapa de puesta en escena muestra el tiempo entre la fusión y el despliegue de código en el entorno de producción. Los datos se añadirán automáticamente una vez que se despliega a producción por primera vez." + +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 "La etapa de pruebas muestra el tiempo que GitLab CI toma para ejecutar cada pipeline para la solicitud de fusión relacionada. Los datos se añadirán automáticamente luego de que el primer pipeline termine de ejecutarse." + +msgid "The time taken by each data entry gathered by that stage." +msgstr "El tiempo utilizado por cada entrada de datos obtenido por esa etapa." + +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 "El valor en el punto medio de una serie de valores observados. Por ejemplo, entre 3, 5, 9, la mediana es 5. Entre 3, 5, 7, 8, la mediana es (5 + 7) / 2 = 6." + +msgid "This means you can not push code until you create an empty repository or import existing one." +msgstr "Esto significa que no puede enviar código hasta que cree un repositorio vacío o importe uno existente." + +msgid "Time before an issue gets scheduled" +msgstr "Tiempo antes de que una incidencia sea programada" + +msgid "Time before an issue starts implementation" +msgstr "Tiempo antes de que empieze la implementación de una incidencia" + +msgid "Time between merge request creation and merge/close" +msgstr "Tiempo entre la creación de la solicitud de fusión y la integración o cierre de ésta" + +msgid "Time until first merge request" +msgstr "Tiempo hasta la primera solicitud de fusión" + +msgid "Timeago|%s days ago" +msgstr "hace %s días" + +msgid "Timeago|%s days remaining" +msgstr "%s días restantes" + +msgid "Timeago|%s hours remaining" +msgstr "%s horas restantes" + +msgid "Timeago|%s minutes ago" +msgstr "hace %s minutos" + +msgid "Timeago|%s minutes remaining" +msgstr "%s minutos restantes" + +msgid "Timeago|%s months ago" +msgstr "hace %s meses" + +msgid "Timeago|%s months remaining" +msgstr "%s meses restantes" + +msgid "Timeago|%s seconds remaining" +msgstr "%s segundos restantes" + +msgid "Timeago|%s weeks ago" +msgstr "hace %s semanas" + +msgid "Timeago|%s weeks remaining" +msgstr "%s semanas restantes" + +msgid "Timeago|%s years ago" +msgstr "hace %s años" + +msgid "Timeago|%s years remaining" +msgstr "%s años restantes" + +msgid "Timeago|1 day remaining" +msgstr "1 día restante" + +msgid "Timeago|1 hour remaining" +msgstr "1 hora restante" + +msgid "Timeago|1 minute remaining" +msgstr "1 minuto restante" + +msgid "Timeago|1 month remaining" +msgstr "1 mes restante" + +msgid "Timeago|1 week remaining" +msgstr "1 semana restante" + +msgid "Timeago|1 year remaining" +msgstr "1 año restante" + +msgid "Timeago|Past due" +msgstr "Atrasado" + +msgid "Timeago|a day ago" +msgstr "hace un día" + +msgid "Timeago|a month ago" +msgstr "hace un mes" + +msgid "Timeago|a week ago" +msgstr "hace una semana" + +msgid "Timeago|a while" +msgstr "hace un momento" + +msgid "Timeago|a year ago" +msgstr "hace un año" + +msgid "Timeago|about %s hours ago" +msgstr "hace alrededor de %s horas" + +msgid "Timeago|about a minute ago" +msgstr "hace alrededor de 1 minuto" + +msgid "Timeago|about an hour ago" +msgstr "hace alrededor de 1 hora" + +msgid "Timeago|in %s days" +msgstr "en %s días" + +msgid "Timeago|in %s hours" +msgstr "en %s horas" + +msgid "Timeago|in %s minutes" +msgstr "en %s minutos" + +msgid "Timeago|in %s months" +msgstr "en %s meses" + +msgid "Timeago|in %s seconds" +msgstr "en %s segundos" + +msgid "Timeago|in %s weeks" +msgstr "en %s semanas" + +msgid "Timeago|in %s years" +msgstr "en %s años" + +msgid "Timeago|in 1 day" +msgstr "en 1 día" + +msgid "Timeago|in 1 hour" +msgstr "en 1 hora" + +msgid "Timeago|in 1 minute" +msgstr "en 1 minuto" + +msgid "Timeago|in 1 month" +msgstr "en 1 mes" + +msgid "Timeago|in 1 week" +msgstr "en 1 semana" + +msgid "Timeago|in 1 year" +msgstr "en 1 año" + +msgid "Timeago|less than a minute ago" +msgstr "hace menos de 1 minuto" + +msgid "Time|hr" +msgid_plural "Time|hrs" +msgstr[0] "hr" +msgstr[1] "hrs" + +msgid "Time|min" +msgid_plural "Time|mins" +msgstr[0] "min" +msgstr[1] "mins" + +msgid "Time|s" +msgstr "s" + +msgid "Total Time" +msgstr "Tiempo Total" + +msgid "Total test time for all commits/merges" +msgstr "Tiempo total de pruebas para todos los cambios o integraciones" + +msgid "Unstar" +msgstr "No Destacar" + +msgid "Upload New File" +msgstr "Subir nuevo archivo" + +msgid "Upload file" +msgstr "Subir archivo" + +msgid "UploadLink|click to upload" +msgstr "Hacer clic para subir" + +msgid "Use your global notification setting" +msgstr "Utiliza tu configuración de notificación global" + +msgid "View open merge request" +msgstr "Ver solicitud de fusión abierta" + +msgid "VisibilityLevel|Internal" +msgstr "Interno" + +msgid "VisibilityLevel|Private" +msgstr "Privado" + +msgid "VisibilityLevel|Public" +msgstr "Público" + +msgid "VisibilityLevel|Unknown" +msgstr "Desconocido" + +msgid "Want to see the data? Please ask an administrator for access." +msgstr "¿Quieres ver los datos? Por favor pide acceso al administrador." + +msgid "We don't have enough data to show this stage." +msgstr "No hay suficientes datos para mostrar en esta etapa." + +msgid "Withdraw Access Request" +msgstr "Retirar Solicitud de Acceso" + +msgid "You are going to remove %{group_name}. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?" +msgstr "Va a eliminar %{group_name}. ¡El grupo eliminado NO puede ser restaurado! ¿Estás TOTALMENTE seguro?" + +msgid "You are going to remove %{project_name_with_namespace}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?" +msgstr "Va a eliminar %{project_name_with_namespace}. ¡El proyecto eliminado NO puede ser restaurado! ¿Estás TOTALMENTE seguro?" + +msgid "You are going to remove the fork relationship to source project %{forked_from_project}. Are you ABSOLUTELY sure?" +msgstr "Vas a eliminar el enlace de la bifurcación con el proyecto original %{forked_from_project}. ¿Estás TOTALMENTE seguro?" + +msgid "You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?" +msgstr "Vas a transferir %{project_name_with_namespace} a otro propietario. ¿Estás TOTALMENTE seguro?" + +msgid "You can only add files when you are on a branch" +msgstr "Solo puedes agregar archivos cuando estás en una rama" + +msgid "You have reached your project limit" +msgstr "Has alcanzado el límite de tu proyecto" + +msgid "You must sign in to star a project" +msgstr "Debes iniciar sesión para destacar un proyecto" + +msgid "You need permission." +msgstr "Necesitas permisos." + +msgid "You will not get any notifications via email" +msgstr "No recibirás ninguna notificación por correo electrónico" + +msgid "You will only receive notifications for the events you choose" +msgstr "Solo recibirás notificaciones de los eventos que elijas" + +msgid "You will only receive notifications for threads you have participated in" +msgstr "Solo recibirás notificaciones de los temas en los que has participado" + +msgid "You will receive notifications for any activity" +msgstr "Recibirás notificaciones por cualquier actividad" + +msgid "You will receive notifications only for comments in which you were @mentioned" +msgstr "Recibirás notificaciones solo para los comentarios en los que se te mencionó" + +msgid "You won't be able to pull or push project code via %{protocol} until you %{set_password_link} on your account" +msgstr "No podrás actualizar o enviar código al proyecto a través de %{protocol} hasta que %{set_password_link} en tu cuenta" + +msgid "You won't be able to pull or push project code via SSH until you %{add_ssh_key_link} to your profile" +msgstr "No podrás actualizar o enviar código al proyecto a través de SSH hasta que %{add_ssh_key_link} en su perfil" + +msgid "Your name" +msgstr "Tu nombre" + +msgid "day" +msgid_plural "days" +msgstr[0] "día" +msgstr[1] "días" + +msgid "new merge request" +msgstr "nueva solicitud de fusión" + +msgid "notification emails" +msgstr "correos electrónicos de notificación" + +msgid "parent" +msgid_plural "parents" +msgstr[0] "padre" +msgstr[1] "padres" diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index c654151564e..04620f6d88c 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -95,13 +95,13 @@ describe BlobHelper do it 'returns a link with the proper route' do link = edit_blob_link(project, 'master', 'README.md') - expect(Capybara.string(link).find_link('Edit')[:href]).to eq('/gitlab/gitlabhq/edit/master/README.md') + expect(Capybara.string(link).find_link('Edit')[:href]).to eq("/#{project.full_path}/edit/master/README.md") end it 'returns a link with the passed link_opts on the expected route' do link = edit_blob_link(project, 'master', 'README.md', link_opts: { mr_id: 10 }) - expect(Capybara.string(link).find_link('Edit')[:href]).to eq('/gitlab/gitlabhq/edit/master/README.md?mr_id=10') + expect(Capybara.string(link).find_link('Edit')[:href]).to eq("/#{project.full_path}/edit/master/README.md?mr_id=10") end end diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 7789cfa3554..ead3e28438e 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -59,112 +59,6 @@ describe IssuablesHelper do .to eq('<span>All</span> <span class="badge">42</span>') end end - - describe 'counter caching based on issuable type and params', :use_clean_rails_memory_store_caching do - let(:params) do - { - scope: 'created-by-me', - state: 'opened', - utf8: '✓', - author_id: '11', - assignee_id: '18', - label_name: %w(bug discussion documentation), - milestone_title: 'v4.0', - sort: 'due_date_asc', - namespace_id: 'gitlab-org', - project_id: 'gitlab-ce', - page: 2 - }.with_indifferent_access - end - - let(:issues_finder) { IssuesFinder.new(nil, params) } - let(:merge_requests_finder) { MergeRequestsFinder.new(nil, params) } - - before do - allow(helper).to receive(:issues_finder).and_return(issues_finder) - allow(helper).to receive(:merge_requests_finder).and_return(merge_requests_finder) - end - - it 'returns the cached value when called for the same issuable type & with the same params' do - expect(issues_finder).to receive(:count_by_state).and_return(opened: 42) - - expect(helper.issuables_state_counter_text(:issues, :opened)) - .to eq('<span>Open</span> <span class="badge">42</span>') - - expect(issues_finder).not_to receive(:count_by_state) - - expect(helper.issuables_state_counter_text(:issues, :opened)) - .to eq('<span>Open</span> <span class="badge">42</span>') - end - - it 'takes confidential status into account when searching for issues' do - expect(issues_finder).to receive(:count_by_state).and_return(opened: 42) - - expect(helper.issuables_state_counter_text(:issues, :opened)) - .to include('42') - - expect(issues_finder).to receive(:user_cannot_see_confidential_issues?).twice.and_return(false) - expect(issues_finder).to receive(:count_by_state).and_return(opened: 40) - - expect(helper.issuables_state_counter_text(:issues, :opened)) - .to include('40') - - expect(issues_finder).to receive(:user_can_see_all_confidential_issues?).and_return(true) - expect(issues_finder).to receive(:count_by_state).and_return(opened: 45) - - expect(helper.issuables_state_counter_text(:issues, :opened)) - .to include('45') - end - - it 'does not take confidential status into account when searching for merge requests' do - expect(merge_requests_finder).to receive(:count_by_state).and_return(opened: 42) - expect(merge_requests_finder).not_to receive(:user_cannot_see_confidential_issues?) - expect(merge_requests_finder).not_to receive(:user_can_see_all_confidential_issues?) - - expect(helper.issuables_state_counter_text(:merge_requests, :opened)) - .to include('42') - end - - it 'does not take some keys into account in the cache key' do - expect(issues_finder).to receive(:count_by_state).and_return(opened: 42) - expect(issues_finder).to receive(:params).and_return({ - author_id: '11', - state: 'foo', - sort: 'foo', - utf8: 'foo', - page: 'foo' - }.with_indifferent_access) - - expect(helper.issuables_state_counter_text(:issues, :opened)) - .to eq('<span>Open</span> <span class="badge">42</span>') - - expect(issues_finder).not_to receive(:count_by_state) - expect(issues_finder).to receive(:params).and_return({ - author_id: '11', - state: 'bar', - sort: 'bar', - utf8: 'bar', - page: 'bar' - }.with_indifferent_access) - - expect(helper.issuables_state_counter_text(:issues, :opened)) - .to eq('<span>Open</span> <span class="badge">42</span>') - end - - it 'does not take params order into account in the cache key' do - expect(issues_finder).to receive(:params).and_return('author_id' => '11', 'state' => 'opened') - expect(issues_finder).to receive(:count_by_state).and_return(opened: 42) - - expect(helper.issuables_state_counter_text(:issues, :opened)) - .to eq('<span>Open</span> <span class="badge">42</span>') - - expect(issues_finder).to receive(:params).and_return('state' => 'opened', 'author_id' => '11') - expect(issues_finder).not_to receive(:count_by_state) - - expect(helper.issuables_state_counter_text(:issues, :opened)) - .to eq('<span>Open</span> <span class="badge">42</span>') - end - end end describe '#issuable_reference' do diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index dc3100311f8..ddf881a7b6f 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -58,16 +58,6 @@ describe IssuesHelper do end end - describe "merge_requests_sentence" do - subject { merge_requests_sentence(merge_requests)} - let(:merge_requests) do - [build(:merge_request, iid: 1), build(:merge_request, iid: 2), - build(:merge_request, iid: 3)] - end - - it { is_expected.to eq("!1, !2, or !3") } - end - describe '#award_user_list' do it "returns a comma-separated list of the first X users" do user = build_stubbed(:user, name: 'Joe') diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js index 8c68ceff914..2aa4fb1f6c6 100644 --- a/spec/javascripts/api_spec.js +++ b/spec/javascripts/api_spec.js @@ -101,12 +101,13 @@ describe('Api', () => { it('fetches projects with membership when logged in', (done) => { const query = 'dummy query'; const options = { unused: 'option' }; - const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json?simple=true`; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; window.gon.current_user_id = 1; const expectedData = Object.assign({ search: query, per_page: 20, membership: true, + simple: true, }, options); spyOn(jQuery, 'ajax').and.callFake((request) => { expect(request.url).toEqual(expectedUrl); @@ -124,10 +125,11 @@ describe('Api', () => { it('fetches projects without membership when not logged in', (done) => { const query = 'dummy query'; const options = { unused: 'option' }; - const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json?simple=true`; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; const expectedData = Object.assign({ search: query, per_page: 20, + simple: true, }, options); spyOn(jQuery, 'ajax').and.callFake((request) => { expect(request.url).toEqual(expectedUrl); diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js index 8e056882108..a22b71fd1dc 100644 --- a/spec/javascripts/awards_handler_spec.js +++ b/spec/javascripts/awards_handler_spec.js @@ -25,9 +25,10 @@ import '~/lib/utils/common_utils'; }; describe('AwardsHandler', function() { - preloadFixtures('issues/issue_with_comment.html.raw'); + preloadFixtures('merge_requests/diff_comment.html.raw'); beforeEach(function(done) { - loadFixtures('issues/issue_with_comment.html.raw'); + loadFixtures('merge_requests/diff_comment.html.raw'); + $('body').data('page', 'projects:merge_requests:show'); loadAwardsHandler(true).then((obj) => { awardsHandler = obj; spyOn(awardsHandler, 'postEmoji').and.callFake((button, url, emoji, cb) => cb()); @@ -139,7 +140,7 @@ import '~/lib/utils/common_utils'; }); describe('::getAwardUrl', function() { return it('returns the url for request', function() { - return expect(awardsHandler.getAwardUrl()).toBe('http://test.host/frontend-fixtures/issues-project/issues/1/toggle_award_emoji'); + return expect(awardsHandler.getAwardUrl()).toBe('http://test.host/frontend-fixtures/merge-requests-project/merge_requests/1/toggle_award_emoji'); }); }); describe('::addAward and ::checkMutuality', function() { diff --git a/spec/javascripts/behaviors/quick_submit_spec.js b/spec/javascripts/behaviors/quick_submit_spec.js index 6dc48f9a293..f62bf43adb9 100644 --- a/spec/javascripts/behaviors/quick_submit_spec.js +++ b/spec/javascripts/behaviors/quick_submit_spec.js @@ -1,119 +1,111 @@ -/* eslint-disable space-before-function-paren, no-var, no-return-assign, comma-dangle, jasmine/no-spec-dupes, new-cap, max-len */ - import '~/behaviors/quick_submit'; -(function() { - describe('Quick Submit behavior', function() { - var keydownEvent; - preloadFixtures('issues/open-issue.html.raw'); - beforeEach(function() { - loadFixtures('issues/open-issue.html.raw'); - $('form').submit(function(e) { - // Prevent a form submit from moving us off the testing page - return e.preventDefault(); - }); - this.spies = { - submit: spyOnEvent('form', 'submit') - }; +describe('Quick Submit behavior', () => { + const keydownEvent = (options = { keyCode: 13, metaKey: true }) => $.Event('keydown', options); - this.textarea = $('.js-quick-submit textarea').first(); - }); - it('does not respond to other keyCodes', function() { - this.textarea.trigger(keydownEvent({ - keyCode: 32 - })); - return expect(this.spies.submit).not.toHaveBeenTriggered(); - }); - it('does not respond to Enter alone', function() { - this.textarea.trigger(keydownEvent({ - ctrlKey: false, - metaKey: false - })); - return expect(this.spies.submit).not.toHaveBeenTriggered(); - }); - it('does not respond to repeated events', function() { - this.textarea.trigger(keydownEvent({ - repeat: true - })); - return expect(this.spies.submit).not.toHaveBeenTriggered(); - }); - it('disables input of type submit', function() { - const submitButton = $('.js-quick-submit input[type=submit]'); - this.textarea.trigger(keydownEvent()); + preloadFixtures('merge_requests/merge_request_with_task_list.html.raw'); - expect(submitButton).toBeDisabled(); + beforeEach(() => { + loadFixtures('merge_requests/merge_request_with_task_list.html.raw'); + $('body').attr('data-page', 'projects:merge_requests:show'); + $('form').submit((e) => { + // Prevent a form submit from moving us off the testing page + e.preventDefault(); }); - it('disables button of type submit', function() { - const submitButton = $('.js-quick-submit input[type=submit]'); - this.textarea.trigger(keydownEvent()); + this.spies = { + submit: spyOnEvent('form', 'submit'), + }; - expect(submitButton).toBeDisabled(); - }); - it('only clicks one submit', function() { - const existingSubmit = $('.js-quick-submit input[type=submit]'); - // Add an extra submit button - const newSubmit = $('<button type="submit">Submit it</button>'); - newSubmit.insertAfter(this.textarea); + this.textarea = $('.js-quick-submit textarea').first(); + }); - const oldClick = spyOnEvent(existingSubmit, 'click'); - const newClick = spyOnEvent(newSubmit, 'click'); + it('does not respond to other keyCodes', () => { + this.textarea.trigger(keydownEvent({ + keyCode: 32, + })); + expect(this.spies.submit).not.toHaveBeenTriggered(); + }); - this.textarea.trigger(keydownEvent()); + it('does not respond to Enter alone', () => { + this.textarea.trigger(keydownEvent({ + ctrlKey: false, + metaKey: false, + })); + expect(this.spies.submit).not.toHaveBeenTriggered(); + }); - expect(oldClick).not.toHaveBeenTriggered(); - expect(newClick).toHaveBeenTriggered(); - }); - // We cannot stub `navigator.userAgent` for CI's `rake karma` task, so we'll - // only run the tests that apply to the current platform - if (navigator.userAgent.match(/Macintosh/)) { - it('responds to Meta+Enter', function() { - this.textarea.trigger(keydownEvent()); - return expect(this.spies.submit).toHaveBeenTriggered(); - }); - it('excludes other modifier keys', function() { - this.textarea.trigger(keydownEvent({ - altKey: true - })); - this.textarea.trigger(keydownEvent({ - ctrlKey: true - })); - this.textarea.trigger(keydownEvent({ - shiftKey: true - })); - return expect(this.spies.submit).not.toHaveBeenTriggered(); - }); - } else { - it('responds to Ctrl+Enter', function() { + it('does not respond to repeated events', () => { + this.textarea.trigger(keydownEvent({ + repeat: true, + })); + expect(this.spies.submit).not.toHaveBeenTriggered(); + }); + + it('disables input of type submit', () => { + const submitButton = $('.js-quick-submit input[type=submit]'); + this.textarea.trigger(keydownEvent()); + + expect(submitButton).toBeDisabled(); + }); + it('disables button of type submit', () => { + const submitButton = $('.js-quick-submit input[type=submit]'); + this.textarea.trigger(keydownEvent()); + + expect(submitButton).toBeDisabled(); + }); + it('only clicks one submit', () => { + const existingSubmit = $('.js-quick-submit input[type=submit]'); + // Add an extra submit button + const newSubmit = $('<button type="submit">Submit it</button>'); + newSubmit.insertAfter(this.textarea); + + const oldClick = spyOnEvent(existingSubmit, 'click'); + const newClick = spyOnEvent(newSubmit, 'click'); + + this.textarea.trigger(keydownEvent()); + + expect(oldClick).not.toHaveBeenTriggered(); + expect(newClick).toHaveBeenTriggered(); + }); + // We cannot stub `navigator.userAgent` for CI's `rake karma` task, so we'll + // only run the tests that apply to the current platform + if (navigator.userAgent.match(/Macintosh/)) { + describe('In Macintosh', () => { + it('responds to Meta+Enter', () => { this.textarea.trigger(keydownEvent()); return expect(this.spies.submit).toHaveBeenTriggered(); }); - it('excludes other modifier keys', function() { + + it('excludes other modifier keys', () => { this.textarea.trigger(keydownEvent({ - altKey: true + altKey: true, })); this.textarea.trigger(keydownEvent({ - metaKey: true + ctrlKey: true, })); this.textarea.trigger(keydownEvent({ - shiftKey: true + shiftKey: true, })); return expect(this.spies.submit).not.toHaveBeenTriggered(); }); - } - return keydownEvent = function(options) { - var defaults; - if (navigator.userAgent.match(/Macintosh/)) { - defaults = { - keyCode: 13, - metaKey: true - }; - } else { - defaults = { - keyCode: 13, - ctrlKey: true - }; - } - return $.Event('keydown', $.extend({}, defaults, options)); - }; - }); -}).call(window); + }); + } else { + it('responds to Ctrl+Enter', () => { + this.textarea.trigger(keydownEvent()); + return expect(this.spies.submit).toHaveBeenTriggered(); + }); + + it('excludes other modifier keys', () => { + this.textarea.trigger(keydownEvent({ + altKey: true, + })); + this.textarea.trigger(keydownEvent({ + metaKey: true, + })); + this.textarea.trigger(keydownEvent({ + shiftKey: true, + })); + return expect(this.spies.submit).not.toHaveBeenTriggered(); + }); + } +}); diff --git a/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js b/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js new file mode 100644 index 00000000000..114d282e48a --- /dev/null +++ b/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js @@ -0,0 +1,219 @@ +import Cookies from 'js-cookie'; +import { + getCookieName, + getSelector, + showPopover, + hidePopover, + dismiss, + mouseleave, + mouseenter, + setupDismissButton, +} from '~/feature_highlight/feature_highlight_helper'; + +describe('feature highlight helper', () => { + describe('getCookieName', () => { + it('returns `feature-highlighted-` prefix', () => { + const cookieId = 'cookieId'; + expect(getCookieName(cookieId)).toEqual(`feature-highlighted-${cookieId}`); + }); + }); + + describe('getSelector', () => { + it('returns js-feature-highlight selector', () => { + const highlightId = 'highlightId'; + expect(getSelector(highlightId)).toEqual(`.js-feature-highlight[data-highlight=${highlightId}]`); + }); + }); + + describe('showPopover', () => { + it('returns true when popover is shown', () => { + const context = { + hasClass: () => false, + popover: () => {}, + addClass: () => {}, + }; + + expect(showPopover.call(context)).toEqual(true); + }); + + it('returns false when popover is already shown', () => { + const context = { + hasClass: () => true, + }; + + expect(showPopover.call(context)).toEqual(false); + }); + + it('shows popover', (done) => { + const context = { + hasClass: () => false, + popover: () => {}, + addClass: () => {}, + }; + + spyOn(context, 'popover').and.callFake((method) => { + expect(method).toEqual('show'); + done(); + }); + + showPopover.call(context); + }); + + it('adds disable-animation and js-popover-show class', (done) => { + const context = { + hasClass: () => false, + popover: () => {}, + addClass: () => {}, + }; + + spyOn(context, 'addClass').and.callFake((classNames) => { + expect(classNames).toEqual('disable-animation js-popover-show'); + done(); + }); + + showPopover.call(context); + }); + }); + + describe('hidePopover', () => { + it('returns true when popover is hidden', () => { + const context = { + hasClass: () => true, + popover: () => {}, + removeClass: () => {}, + }; + + expect(hidePopover.call(context)).toEqual(true); + }); + + it('returns false when popover is already hidden', () => { + const context = { + hasClass: () => false, + }; + + expect(hidePopover.call(context)).toEqual(false); + }); + + it('hides popover', (done) => { + const context = { + hasClass: () => true, + popover: () => {}, + removeClass: () => {}, + }; + + spyOn(context, 'popover').and.callFake((method) => { + expect(method).toEqual('hide'); + done(); + }); + + hidePopover.call(context); + }); + + it('removes disable-animation and js-popover-show class', (done) => { + const context = { + hasClass: () => true, + popover: () => {}, + removeClass: () => {}, + }; + + spyOn(context, 'removeClass').and.callFake((classNames) => { + expect(classNames).toEqual('disable-animation js-popover-show'); + done(); + }); + + hidePopover.call(context); + }); + }); + + describe('dismiss', () => { + const context = { + hide: () => {}, + }; + + beforeEach(() => { + spyOn(Cookies, 'set').and.callFake(() => {}); + spyOn(hidePopover, 'call').and.callFake(() => {}); + spyOn(context, 'hide').and.callFake(() => {}); + dismiss.call(context); + }); + + it('sets cookie to true', () => { + expect(Cookies.set).toHaveBeenCalled(); + }); + + it('calls hide popover', () => { + expect(hidePopover.call).toHaveBeenCalled(); + }); + + it('calls hide', () => { + expect(context.hide).toHaveBeenCalled(); + }); + }); + + describe('mouseleave', () => { + it('calls hide popover if .popover:hover is false', () => { + const fakeJquery = { + length: 0, + }; + + spyOn($.fn, 'init').and.callFake(selector => (selector === '.popover:hover' ? fakeJquery : $.fn)); + spyOn(hidePopover, 'call'); + mouseleave(); + expect(hidePopover.call).toHaveBeenCalled(); + }); + + it('does not call hide popover if .popover:hover is true', () => { + const fakeJquery = { + length: 1, + }; + + spyOn($.fn, 'init').and.callFake(selector => (selector === '.popover:hover' ? fakeJquery : $.fn)); + spyOn(hidePopover, 'call'); + mouseleave(); + expect(hidePopover.call).not.toHaveBeenCalled(); + }); + }); + + describe('mouseenter', () => { + const context = {}; + + it('shows popover', () => { + spyOn(showPopover, 'call').and.returnValue(false); + mouseenter.call(context); + expect(showPopover.call).toHaveBeenCalled(); + }); + + it('registers mouseleave event if popover is showed', (done) => { + spyOn(showPopover, 'call').and.returnValue(true); + spyOn($.fn, 'on').and.callFake((eventName) => { + expect(eventName).toEqual('mouseleave'); + done(); + }); + mouseenter.call(context); + }); + + it('does not register mouseleave event if popover is not showed', () => { + spyOn(showPopover, 'call').and.returnValue(false); + const spy = spyOn($.fn, 'on').and.callFake(() => {}); + mouseenter.call(context); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + describe('setupDismissButton', () => { + it('registers click event callback', (done) => { + const context = { + getAttribute: () => 'popoverId', + dataset: { + highlight: 'cookieId', + }, + }; + + spyOn($.fn, 'on').and.callFake((event) => { + expect(event).toEqual('click'); + done(); + }); + setupDismissButton.call(context); + }); + }); +}); diff --git a/spec/javascripts/feature_highlight/feature_highlight_options_spec.js b/spec/javascripts/feature_highlight/feature_highlight_options_spec.js new file mode 100644 index 00000000000..7feb361edec --- /dev/null +++ b/spec/javascripts/feature_highlight/feature_highlight_options_spec.js @@ -0,0 +1,45 @@ +import domContentLoaded from '~/feature_highlight/feature_highlight_options'; +import bp from '~/breakpoints'; + +describe('feature highlight options', () => { + describe('domContentLoaded', () => { + const highlightOrder = []; + + beforeEach(() => { + // Check for when highlightFeatures is called + spyOn(highlightOrder, 'find').and.callFake(() => {}); + }); + + it('should not call highlightFeatures when breakpoint is xs', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('xs'); + + domContentLoaded(highlightOrder); + expect(bp.getBreakpointSize).toHaveBeenCalled(); + expect(highlightOrder.find).not.toHaveBeenCalled(); + }); + + it('should not call highlightFeatures when breakpoint is sm', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('sm'); + + domContentLoaded(highlightOrder); + expect(bp.getBreakpointSize).toHaveBeenCalled(); + expect(highlightOrder.find).not.toHaveBeenCalled(); + }); + + it('should not call highlightFeatures when breakpoint is md', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('md'); + + domContentLoaded(highlightOrder); + expect(bp.getBreakpointSize).toHaveBeenCalled(); + expect(highlightOrder.find).not.toHaveBeenCalled(); + }); + + it('should call highlightFeatures when breakpoint is lg', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('lg'); + + domContentLoaded(highlightOrder); + expect(bp.getBreakpointSize).toHaveBeenCalled(); + expect(highlightOrder.find).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/feature_highlight/feature_highlight_spec.js b/spec/javascripts/feature_highlight/feature_highlight_spec.js new file mode 100644 index 00000000000..6abe8425ee7 --- /dev/null +++ b/spec/javascripts/feature_highlight/feature_highlight_spec.js @@ -0,0 +1,122 @@ +import Cookies from 'js-cookie'; +import * as featureHighlightHelper from '~/feature_highlight/feature_highlight_helper'; +import * as featureHighlight from '~/feature_highlight/feature_highlight'; + +describe('feature highlight', () => { + describe('setupFeatureHighlightPopover', () => { + const selector = '.js-feature-highlight[data-highlight=test]'; + beforeEach(() => { + setFixtures(` + <div> + <div class="js-feature-highlight" data-highlight="test" disabled> + Trigger + </div> + </div> + <div class="feature-highlight-popover-content"> + Content + <div class="dismiss-feature-highlight"> + Dismiss + </div> + </div> + `); + spyOn(window, 'addEventListener'); + spyOn(window, 'removeEventListener'); + featureHighlight.setupFeatureHighlightPopover('test', 0); + }); + + it('setups popover content', () => { + const $popoverContent = $('.feature-highlight-popover-content'); + const outerHTML = $popoverContent.prop('outerHTML'); + + expect($(selector).data('content')).toEqual(outerHTML); + }); + + it('setups mouseenter', () => { + const showSpy = spyOn(featureHighlightHelper.showPopover, 'call'); + $(selector).trigger('mouseenter'); + + expect(showSpy).toHaveBeenCalled(); + }); + + it('setups debounced mouseleave', (done) => { + const hideSpy = spyOn(featureHighlightHelper.hidePopover, 'call'); + $(selector).trigger('mouseleave'); + + // Even though we've set the debounce to 0ms, setTimeout is needed for the debounce + setTimeout(() => { + expect(hideSpy).toHaveBeenCalled(); + done(); + }, 0); + }); + + it('setups inserted.bs.popover', () => { + $(selector).trigger('mouseenter'); + const popoverId = $(selector).attr('aria-describedby'); + const spyEvent = spyOnEvent(`#${popoverId} .dismiss-feature-highlight`, 'click'); + + $(`#${popoverId} .dismiss-feature-highlight`).click(); + expect(spyEvent).toHaveBeenTriggered(); + }); + + it('setups show.bs.popover', () => { + $(selector).trigger('show.bs.popover'); + expect(window.addEventListener).toHaveBeenCalledWith('scroll', jasmine.any(Function)); + }); + + it('setups hide.bs.popover', () => { + $(selector).trigger('hide.bs.popover'); + expect(window.removeEventListener).toHaveBeenCalledWith('scroll', jasmine.any(Function)); + }); + + it('removes disabled attribute', () => { + expect($('.js-feature-highlight').is(':disabled')).toEqual(false); + }); + + it('displays popover', () => { + expect($(selector).attr('aria-describedby')).toBeFalsy(); + $(selector).trigger('mouseenter'); + expect($(selector).attr('aria-describedby')).toBeTruthy(); + }); + }); + + describe('shouldHighlightFeature', () => { + it('should return false if element is not found', () => { + spyOn(document, 'querySelector').and.returnValue(null); + spyOn(Cookies, 'get').and.returnValue(null); + + expect(featureHighlight.shouldHighlightFeature()).toBeFalsy(); + }); + + it('should return false if previouslyDismissed', () => { + spyOn(document, 'querySelector').and.returnValue(document.createElement('div')); + spyOn(Cookies, 'get').and.returnValue('true'); + + expect(featureHighlight.shouldHighlightFeature()).toBeFalsy(); + }); + + it('should return true if element is found and not previouslyDismissed', () => { + spyOn(document, 'querySelector').and.returnValue(document.createElement('div')); + spyOn(Cookies, 'get').and.returnValue(null); + + expect(featureHighlight.shouldHighlightFeature()).toBeTruthy(); + }); + }); + + describe('highlightFeatures', () => { + it('calls setupFeatureHighlightPopover if shouldHighlightFeature returns true', () => { + // Mimic shouldHighlightFeature set to true + const highlightOrder = ['issue-boards']; + spyOn(highlightOrder, 'find').and.returnValue(highlightOrder[0]); + + expect(featureHighlight.highlightFeatures(highlightOrder)).toEqual(true); + }); + + it('does not call setupFeatureHighlightPopover if shouldHighlightFeature returns false', () => { + // Mimic shouldHighlightFeature set to false + const highlightOrder = ['issue-boards']; + spyOn(highlightOrder, 'find').and.returnValue(null); + + expect(featureHighlight.highlightFeatures(highlightOrder)).toEqual(false); + }); + }); +}); diff --git a/spec/javascripts/fixtures/blob.rb b/spec/javascripts/fixtures/blob.rb index 2dffc42b0ef..81e8a51a902 100644 --- a/spec/javascripts/fixtures/blob.rb +++ b/spec/javascripts/fixtures/blob.rb @@ -17,6 +17,10 @@ describe Projects::BlobController, '(JavaScript fixtures)', type: :controller do sign_in(admin) end + after do + remove_repository(project) + end + it 'blob/show.html.raw' do |example| get(:show, namespace_id: project.namespace, diff --git a/spec/javascripts/fixtures/branches.rb b/spec/javascripts/fixtures/branches.rb index bb3bdf7c215..4fc072d2585 100644 --- a/spec/javascripts/fixtures/branches.rb +++ b/spec/javascripts/fixtures/branches.rb @@ -17,6 +17,10 @@ describe Projects::BranchesController, '(JavaScript fixtures)', type: :controlle sign_in(admin) end + after do + remove_repository(project) + end + it 'branches/new_branch.html.raw' do |example| get :new, namespace_id: project.namespace.to_param, diff --git a/spec/javascripts/fixtures/dashboard.rb b/spec/javascripts/fixtures/dashboard.rb index 793ffa7c220..7fa351680c9 100644 --- a/spec/javascripts/fixtures/dashboard.rb +++ b/spec/javascripts/fixtures/dashboard.rb @@ -17,6 +17,10 @@ describe Dashboard::ProjectsController, '(JavaScript fixtures)', type: :controll sign_in(admin) end + after do + remove_repository(project) + end + it 'dashboard/user-callout.html.raw' do |example| rendered = render_template('shared/_user_callout') store_frontend_fixture(rendered, example.description) diff --git a/spec/javascripts/fixtures/deploy_keys.rb b/spec/javascripts/fixtures/deploy_keys.rb index bea161c514f..580894ceaf9 100644 --- a/spec/javascripts/fixtures/deploy_keys.rb +++ b/spec/javascripts/fixtures/deploy_keys.rb @@ -16,6 +16,10 @@ describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :control sign_in(admin) end + after do + remove_repository(project) + end + render_views it 'deploy_keys/keys.json' do |example| diff --git a/spec/javascripts/fixtures/issues.rb b/spec/javascripts/fixtures/issues.rb index d3ad50af1b9..0ee2f82dfd6 100644 --- a/spec/javascripts/fixtures/issues.rb +++ b/spec/javascripts/fixtures/issues.rb @@ -17,6 +17,10 @@ describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller sign_in(admin) end + after do + remove_repository(project) + end + it 'issues/open-issue.html.raw' do |example| render_issue(example.description, create(:issue, project: project)) end diff --git a/spec/javascripts/fixtures/jobs.rb b/spec/javascripts/fixtures/jobs.rb index 83a96797506..87d131dfe28 100644 --- a/spec/javascripts/fixtures/jobs.rb +++ b/spec/javascripts/fixtures/jobs.rb @@ -21,6 +21,10 @@ describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do sign_in(admin) end + after do + remove_repository(project) + end + it 'builds/build-with-artifacts.html.raw' do |example| get :show, namespace_id: project.namespace.to_param, diff --git a/spec/javascripts/fixtures/labels.rb b/spec/javascripts/fixtures/labels.rb index 814f065f3a4..b730d557e21 100644 --- a/spec/javascripts/fixtures/labels.rb +++ b/spec/javascripts/fixtures/labels.rb @@ -19,6 +19,10 @@ describe 'Labels (JavaScript fixtures)' do clean_frontend_fixtures('labels/') end + after do + remove_repository(project) + end + describe Groups::LabelsController, '(JavaScript fixtures)', type: :controller do render_views diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb index f97a5d2b5de..4bc2205e642 100644 --- a/spec/javascripts/fixtures/merge_requests.rb +++ b/spec/javascripts/fixtures/merge_requests.rb @@ -37,6 +37,10 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont sign_in(admin) end + after do + remove_repository(project) + end + it 'merge_requests/merge_request_with_task_list.html.raw' do |example| create(:ci_build, :pending, pipeline: pipeline) @@ -55,6 +59,11 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont render_merge_request(example.description, merge_request) end + it 'merge_requests/merge_request_with_comment.html.raw' do |example| + create(:note_on_merge_request, author: admin, project: project, noteable: merge_request, note: '- [ ] Task List Item') + render_merge_request(example.description, merge_request) + end + private def render_merge_request(fixture_file_name, merge_request) diff --git a/spec/javascripts/fixtures/merge_requests_diffs.rb b/spec/javascripts/fixtures/merge_requests_diffs.rb index 6e0a97d2e3f..ddce00bc0fe 100644 --- a/spec/javascripts/fixtures/merge_requests_diffs.rb +++ b/spec/javascripts/fixtures/merge_requests_diffs.rb @@ -29,6 +29,10 @@ describe Projects::MergeRequests::DiffsController, '(JavaScript fixtures)', type sign_in(admin) end + after do + remove_repository(project) + end + it 'merge_request_diffs/inline_changes_tab_with_comments.json' 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) diff --git a/spec/javascripts/fixtures/projects.rb b/spec/javascripts/fixtures/projects.rb index f09d44a49d1..2a100e7fab5 100644 --- a/spec/javascripts/fixtures/projects.rb +++ b/spec/javascripts/fixtures/projects.rb @@ -17,6 +17,10 @@ describe ProjectsController, '(JavaScript fixtures)', type: :controller do sign_in(admin) end + after do + remove_repository(project) + end + it 'projects/dashboard.html.raw' do |example| get :show, namespace_id: project.namespace.to_param, diff --git a/spec/javascripts/fixtures/prometheus_service.rb b/spec/javascripts/fixtures/prometheus_service.rb index 7968c9425f2..f95f8038ffb 100644 --- a/spec/javascripts/fixtures/prometheus_service.rb +++ b/spec/javascripts/fixtures/prometheus_service.rb @@ -18,6 +18,10 @@ describe Projects::ServicesController, '(JavaScript fixtures)', type: :controlle sign_in(admin) end + after do + remove_repository(project) + end + it 'services/prometheus/prometheus_service.html.raw' do |example| get :edit, namespace_id: namespace, diff --git a/spec/javascripts/fixtures/raw.rb b/spec/javascripts/fixtures/raw.rb index 25f5a3b0bb3..82770beb39b 100644 --- a/spec/javascripts/fixtures/raw.rb +++ b/spec/javascripts/fixtures/raw.rb @@ -10,6 +10,10 @@ describe 'Raw files', '(JavaScript fixtures)', type: :controller do clean_frontend_fixtures('blob/notebook/') end + after do + remove_repository(project) + end + it 'blob/notebook/basic.json' do |example| blob = project.repository.blob_at('6d85bb69', 'files/ipython/basic.ipynb') diff --git a/spec/javascripts/fixtures/services.rb b/spec/javascripts/fixtures/services.rb index 80915c32a74..9280ed5a7f1 100644 --- a/spec/javascripts/fixtures/services.rb +++ b/spec/javascripts/fixtures/services.rb @@ -18,6 +18,10 @@ describe Projects::ServicesController, '(JavaScript fixtures)', type: :controlle sign_in(admin) end + after do + remove_repository(project) + end + it 'services/edit_service.html.raw' do |example| get :edit, namespace_id: namespace, diff --git a/spec/javascripts/fixtures/snippet.rb b/spec/javascripts/fixtures/snippet.rb index 01bfb87b0c1..fa97f352e31 100644 --- a/spec/javascripts/fixtures/snippet.rb +++ b/spec/javascripts/fixtures/snippet.rb @@ -18,6 +18,10 @@ describe SnippetsController, '(JavaScript fixtures)', type: :controller do sign_in(admin) end + after do + remove_repository(project) + end + it 'snippets/show.html.raw' do |example| get(:show, id: snippet.to_param) diff --git a/spec/javascripts/fixtures/todos.rb b/spec/javascripts/fixtures/todos.rb index ba630365c18..426b854fe8b 100644 --- a/spec/javascripts/fixtures/todos.rb +++ b/spec/javascripts/fixtures/todos.rb @@ -15,6 +15,10 @@ describe 'Todos (JavaScript fixtures)' do clean_frontend_fixtures('todos/') end + after do + remove_repository(project) + end + describe Dashboard::TodosController, '(JavaScript fixtures)', type: :controller do render_views diff --git a/spec/javascripts/fly_out_nav_spec.js b/spec/javascripts/fly_out_nav_spec.js index 0847e463577..4588bf3d971 100644 --- a/spec/javascripts/fly_out_nav_spec.js +++ b/spec/javascripts/fly_out_nav_spec.js @@ -5,12 +5,14 @@ import { canShowActiveSubItems, mouseEnterTopItems, mouseLeaveTopItem, + getOpenMenu, setOpenMenu, mousePos, getHideSubItemsInterval, documentMouseMove, getHeaderHeight, setSidebar, + subItemsMouseLeave, } from '~/fly_out_nav'; import bp from '~/breakpoints'; @@ -314,4 +316,29 @@ describe('Fly out sidebar navigation', () => { ).toBeTruthy(); }); }); + + describe('subItemsMouseLeave', () => { + beforeEach(() => { + el.innerHTML = '<div class="sidebar-sub-level-items" style="position: absolute;"></div>'; + + setOpenMenu(el.querySelector('.sidebar-sub-level-items')); + }); + + it('hides subMenu if element is not hovered', () => { + subItemsMouseLeave(el); + + expect( + getOpenMenu(), + ).toBeNull(); + }); + + it('does not hide subMenu if element is hovered', () => { + el.classList.add('is-over'); + subItemsMouseLeave(el); + + expect( + getOpenMenu(), + ).not.toBeNull(); + }); + }); }); diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index 81ce18bf2fb..39065814bc2 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -34,16 +34,14 @@ describe('Issuable output', () => { propsData: { canUpdate: true, canDestroy: true, - canMove: true, endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes', issuableRef: '#1', initialTitleHtml: '', initialTitleText: '', initialDescriptionHtml: '', initialDescriptionText: '', - markdownPreviewUrl: '/', - markdownDocs: '/', - projectsAutocompleteUrl: '/', + markdownPreviewPath: '/', + markdownDocsPath: '/', isConfidential: false, projectNamespace: '/', projectPath: '/', @@ -226,7 +224,7 @@ describe('Issuable output', () => { }); }); - it('redirects if issue is moved', (done) => { + it('redirects if returned web_url has changed', (done) => { spyOn(gl.utils, 'visitUrl'); spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { resolve({ @@ -250,23 +248,6 @@ describe('Issuable output', () => { }); }); - it('does not update issuable if project move confirm is false', (done) => { - spyOn(window, 'confirm').and.returnValue(false); - spyOn(vm.service, 'updateIssuable'); - - vm.store.formState.move_to_project_id = 1; - - vm.updateIssuable(); - - setTimeout(() => { - expect( - vm.service.updateIssuable, - ).not.toHaveBeenCalled(); - - done(); - }); - }); - it('closes form on error', (done) => { spyOn(window, 'Flash').and.callThrough(); spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve, reject) => { diff --git a/spec/javascripts/issue_show/components/fields/description_spec.js b/spec/javascripts/issue_show/components/fields/description_spec.js index df8189d9290..299f88e7778 100644 --- a/spec/javascripts/issue_show/components/fields/description_spec.js +++ b/spec/javascripts/issue_show/components/fields/description_spec.js @@ -25,8 +25,8 @@ describe('Description field component', () => { vm = new Component({ el, propsData: { - markdownPreviewUrl: '/', - markdownDocs: '/', + markdownPreviewPath: '/', + markdownDocsPath: '/', formState: store.formState, }, }).$mount(); diff --git a/spec/javascripts/issue_show/components/fields/project_move_spec.js b/spec/javascripts/issue_show/components/fields/project_move_spec.js deleted file mode 100644 index 86d35c33ff4..00000000000 --- a/spec/javascripts/issue_show/components/fields/project_move_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import Vue from 'vue'; -import projectMove from '~/issue_show/components/fields/project_move.vue'; - -describe('Project move field component', () => { - let vm; - let formState; - - beforeEach((done) => { - const Component = Vue.extend(projectMove); - - formState = { - move_to_project_id: 0, - }; - - vm = new Component({ - propsData: { - formState, - projectsAutocompleteUrl: '/autocomplete', - }, - }).$mount(); - - Vue.nextTick(done); - }); - - it('mounts select2 element', () => { - expect( - vm.$el.querySelector('.select2-container'), - ).not.toBeNull(); - }); - - it('updates formState on change', () => { - $(vm.$refs['move-dropdown']).val(2).trigger('change'); - - expect( - formState.move_to_project_id, - ).toBe(2); - }); -}); diff --git a/spec/javascripts/issue_show/components/form_spec.js b/spec/javascripts/issue_show/components/form_spec.js index 9a85223208c..6e89528a3ea 100644 --- a/spec/javascripts/issue_show/components/form_spec.js +++ b/spec/javascripts/issue_show/components/form_spec.js @@ -12,15 +12,13 @@ describe('Inline edit form component', () => { vm = new Component({ propsData: { canDestroy: true, - canMove: true, formState: { title: 'b', description: 'a', lockedWarningVisible: false, }, - markdownPreviewUrl: '/', - markdownDocs: '/', - projectsAutocompleteUrl: '/', + markdownPreviewPath: '/', + markdownDocsPath: '/', projectPath: '/', projectNamespace: '/', }, diff --git a/spec/javascripts/monitoring/graph/flag_spec.js b/spec/javascripts/monitoring/graph/flag_spec.js index 731076a7d2a..14794cbfd50 100644 --- a/spec/javascripts/monitoring/graph/flag_spec.js +++ b/spec/javascripts/monitoring/graph/flag_spec.js @@ -32,10 +32,6 @@ describe('GraphFlag', () => { .toEqual(component.currentXCoordinate); expect(getCoordinate(component, '.selected-metric-line', 'x2')) .toEqual(component.currentXCoordinate); - expect(getCoordinate(component, '.circle-metric', 'cx')) - .toEqual(component.currentXCoordinate); - expect(getCoordinate(component, '.circle-metric', 'cy')) - .toEqual(component.currentYCoordinate); }); it('has a SVG with the class rect-text-metric at the currentFlagPosition', () => { diff --git a/spec/javascripts/monitoring/graph/legend_spec.js b/spec/javascripts/monitoring/graph/legend_spec.js index e877832dffd..da2fbd26e23 100644 --- a/spec/javascripts/monitoring/graph/legend_spec.js +++ b/spec/javascripts/monitoring/graph/legend_spec.js @@ -1,6 +1,8 @@ import Vue from 'vue'; import GraphLegend from '~/monitoring/components/graph/legend.vue'; import measurements from '~/monitoring/utils/measurements'; +import createTimeSeries from '~/monitoring/utils/multiple_time_series'; +import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data'; const createComponent = (propsData) => { const Component = Vue.extend(GraphLegend); @@ -10,6 +12,28 @@ const createComponent = (propsData) => { }).$mount(); }; +const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); + +const defaultValuesComponent = { + graphWidth: 500, + graphHeight: 300, + graphHeightOffset: 120, + margin: measurements.large.margin, + measurements: measurements.large, + areaColorRgb: '#f0f0f0', + legendTitle: 'Title', + yAxisLabel: 'Values', + metricUsage: 'Value', + unitOfDisplay: 'Req/Sec', + currentDataIndex: 0, +}; + +const timeSeries = createTimeSeries(convertedMetrics[0].queries[0].result, + defaultValuesComponent.graphWidth, defaultValuesComponent.graphHeight, + defaultValuesComponent.graphHeightOffset); + +defaultValuesComponent.timeSeries = timeSeries; + function getTextFromNode(component, selector) { return component.$el.querySelector(selector).firstChild.nodeValue.trim(); } @@ -17,95 +41,67 @@ function getTextFromNode(component, selector) { describe('GraphLegend', () => { describe('Computed props', () => { it('textTransform', () => { - const component = createComponent({ - graphWidth: 500, - graphHeight: 300, - margin: measurements.large.margin, - measurements: measurements.large, - areaColorRgb: '#f0f0f0', - legendTitle: 'Title', - yAxisLabel: 'Values', - metricUsage: 'Value', - }); + const component = createComponent(defaultValuesComponent); expect(component.textTransform).toContain('translate(15, 120) rotate(-90)'); }); it('xPosition', () => { - const component = createComponent({ - graphWidth: 500, - graphHeight: 300, - margin: measurements.large.margin, - measurements: measurements.large, - areaColorRgb: '#f0f0f0', - legendTitle: 'Title', - yAxisLabel: 'Values', - metricUsage: 'Value', - }); + const component = createComponent(defaultValuesComponent); expect(component.xPosition).toEqual(180); }); it('yPosition', () => { - const component = createComponent({ - graphWidth: 500, - graphHeight: 300, - margin: measurements.large.margin, - measurements: measurements.large, - areaColorRgb: '#f0f0f0', - legendTitle: 'Title', - yAxisLabel: 'Values', - metricUsage: 'Value', - }); + const component = createComponent(defaultValuesComponent); expect(component.yPosition).toEqual(240); }); it('rectTransform', () => { - const component = createComponent({ - graphWidth: 500, - graphHeight: 300, - margin: measurements.large.margin, - measurements: measurements.large, - areaColorRgb: '#f0f0f0', - legendTitle: 'Title', - yAxisLabel: 'Values', - metricUsage: 'Value', - }); + const component = createComponent(defaultValuesComponent); expect(component.rectTransform).toContain('translate(0, 120) rotate(-90)'); }); }); - it('has 2 rect-axis-text rect svg elements', () => { - const component = createComponent({ - graphWidth: 500, - graphHeight: 300, - margin: measurements.large.margin, - measurements: measurements.large, - areaColorRgb: '#f0f0f0', - legendTitle: 'Title', - yAxisLabel: 'Values', - metricUsage: 'Value', + describe('methods', () => { + it('translateLegendGroup should only change Y direction', () => { + const component = createComponent(defaultValuesComponent); + + const translatedCoordinate = component.translateLegendGroup(1); + expect(translatedCoordinate.indexOf('translate(0, ')).not.toEqual(-1); }); + it('formatMetricUsage should contain the unit of display and the current value selected via "currentDataIndex"', () => { + const component = createComponent(defaultValuesComponent); + + const formattedMetricUsage = component.formatMetricUsage(timeSeries[0]); + const valueFromSeries = timeSeries[0].values[component.currentDataIndex].value; + expect(formattedMetricUsage.indexOf(component.unitOfDisplay)).not.toEqual(-1); + expect(formattedMetricUsage.indexOf(valueFromSeries)).not.toEqual(-1); + }); + }); + + it('has 2 rect-axis-text rect svg elements', () => { + const component = createComponent(defaultValuesComponent); + expect(component.$el.querySelectorAll('.rect-axis-text').length).toEqual(2); }); it('contains text to signal the usage, title and time', () => { - const component = createComponent({ - graphWidth: 500, - graphHeight: 300, - margin: measurements.large.margin, - measurements: measurements.large, - areaColorRgb: '#f0f0f0', - legendTitle: 'Title', - yAxisLabel: 'Values', - metricUsage: 'Value', - }); + const component = createComponent(defaultValuesComponent); + const titles = component.$el.querySelectorAll('.legend-metric-title'); + + expect(getTextFromNode(component, '.legend-metric-title').indexOf(component.legendTitle)).not.toEqual(-1); + expect(titles[0].textContent.indexOf('Title')).not.toEqual(-1); + expect(titles[1].textContent.indexOf('Series')).not.toEqual(-1); + expect(getTextFromNode(component, '.y-label-text')).toEqual(component.yAxisLabel); + }); + + it('should contain the same number of legend groups as the timeSeries length', () => { + const component = createComponent(defaultValuesComponent); - expect(getTextFromNode(component, '.text-metric-title')).toEqual(component.legendTitle); - expect(getTextFromNode(component, '.text-metric-usage')).toEqual(component.metricUsage); - expect(getTextFromNode(component, '.label-axis-text')).toEqual(component.yAxisLabel); + expect(component.$el.querySelectorAll('.legend-group').length).toEqual(component.timeSeries.length); }); }); diff --git a/spec/javascripts/monitoring/graph_row_spec.js b/spec/javascripts/monitoring/graph_row_spec.js index dd485473ccf..6a79d7c8f82 100644 --- a/spec/javascripts/monitoring/graph_row_spec.js +++ b/spec/javascripts/monitoring/graph_row_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import GraphRow from '~/monitoring/components/graph_row.vue'; import MonitoringMixins from '~/monitoring/mixins/monitoring_mixins'; -import { deploymentData, singleRowMetrics } from './mock_data'; +import { deploymentData, convertDatesMultipleSeries, singleRowMetricsMultipleSeries } from './mock_data'; const createComponent = (propsData) => { const Component = Vue.extend(GraphRow); @@ -11,15 +11,15 @@ const createComponent = (propsData) => { }).$mount(); }; +const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); describe('GraphRow', () => { beforeEach(() => { spyOn(MonitoringMixins.methods, 'formatDeployments').and.returnValue({}); }); - describe('Computed props', () => { it('bootstrapClass is set to col-md-6 when rowData is higher/equal to 2', () => { const component = createComponent({ - rowData: singleRowMetrics, + rowData: convertedMetrics, updateAspectRatio: false, deploymentData, }); @@ -29,7 +29,7 @@ describe('GraphRow', () => { it('bootstrapClass is set to col-md-12 when rowData is lower than 2', () => { const component = createComponent({ - rowData: [singleRowMetrics[0]], + rowData: [convertedMetrics[0]], updateAspectRatio: false, deploymentData, }); @@ -40,7 +40,7 @@ describe('GraphRow', () => { it('has one column', () => { const component = createComponent({ - rowData: singleRowMetrics, + rowData: convertedMetrics, updateAspectRatio: false, deploymentData, }); @@ -51,7 +51,7 @@ describe('GraphRow', () => { it('has two columns', () => { const component = createComponent({ - rowData: singleRowMetrics, + rowData: convertedMetrics, updateAspectRatio: false, deploymentData, }); diff --git a/spec/javascripts/monitoring/graph_spec.js b/spec/javascripts/monitoring/graph_spec.js index 6d6fe410113..7d8b0744af1 100644 --- a/spec/javascripts/monitoring/graph_spec.js +++ b/spec/javascripts/monitoring/graph_spec.js @@ -1,9 +1,8 @@ import Vue from 'vue'; -import _ from 'underscore'; import Graph from '~/monitoring/components/graph.vue'; import MonitoringMixins from '~/monitoring/mixins/monitoring_mixins'; import eventHub from '~/monitoring/event_hub'; -import { deploymentData, singleRowMetrics } from './mock_data'; +import { deploymentData, convertDatesMultipleSeries, singleRowMetricsMultipleSeries } from './mock_data'; const createComponent = (propsData) => { const Component = Vue.extend(Graph); @@ -13,6 +12,8 @@ const createComponent = (propsData) => { }).$mount(); }; +const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); + describe('Graph', () => { beforeEach(() => { spyOn(MonitoringMixins.methods, 'formatDeployments').and.returnValue({}); @@ -20,7 +21,7 @@ describe('Graph', () => { it('has a title', () => { const component = createComponent({ - graphData: singleRowMetrics[0], + graphData: convertedMetrics[1], classType: 'col-md-6', updateAspectRatio: false, deploymentData, @@ -29,29 +30,10 @@ describe('Graph', () => { expect(component.$el.querySelector('.text-center').innerText.trim()).toBe(component.graphData.title); }); - it('creates a path for the line and area of the graph', (done) => { - const component = createComponent({ - graphData: singleRowMetrics[0], - classType: 'col-md-6', - updateAspectRatio: false, - deploymentData, - }); - - Vue.nextTick(() => { - expect(component.area).toBeDefined(); - expect(component.line).toBeDefined(); - expect(typeof component.area).toEqual('string'); - expect(typeof component.line).toEqual('string'); - expect(_.isFunction(component.xScale)).toBe(true); - expect(_.isFunction(component.yScale)).toBe(true); - done(); - }); - }); - describe('Computed props', () => { it('axisTransform translates an element Y position depending of its height', () => { const component = createComponent({ - graphData: singleRowMetrics[0], + graphData: convertedMetrics[1], classType: 'col-md-6', updateAspectRatio: false, deploymentData, @@ -64,7 +46,7 @@ describe('Graph', () => { it('outterViewBox gets a width and height property based on the DOM size of the element', () => { const component = createComponent({ - graphData: singleRowMetrics[0], + graphData: convertedMetrics[1], classType: 'col-md-6', updateAspectRatio: false, deploymentData, @@ -79,7 +61,7 @@ describe('Graph', () => { it('sends an event to the eventhub when it has finished resizing', (done) => { const component = createComponent({ - graphData: singleRowMetrics[0], + graphData: convertedMetrics[1], classType: 'col-md-6', updateAspectRatio: false, deploymentData, @@ -95,7 +77,7 @@ describe('Graph', () => { it('has a title for the y-axis and the chart legend that comes from the backend', () => { const component = createComponent({ - graphData: singleRowMetrics[0], + graphData: convertedMetrics[1], classType: 'col-md-6', updateAspectRatio: false, deploymentData, diff --git a/spec/javascripts/monitoring/mock_data.js b/spec/javascripts/monitoring/mock_data.js index b69f4eddffc..3d399f2bb95 100644 --- a/spec/javascripts/monitoring/mock_data.js +++ b/spec/javascripts/monitoring/mock_data.js @@ -2473,1754 +2473,5848 @@ export const statePaths = { documentationPath: '/help/administration/monitoring/prometheus/index.md', }; -export const singleRowMetrics = [ - { - 'title': 'CPU usage', - 'weight': 1, - 'y_label': 'Memory', - 'queries': [ - { - 'query_range': 'avg(rate(container_cpu_usage_seconds_total{%{environment_filter}}[2m])) * 100', - 'label': 'Container CPU', - 'result': [ - { - 'metric': { - - }, - 'values': [ - { - 'time': '2017-06-04T21:22:59.508Z', - 'value': '0.06335544298150002' - }, - { - 'time': '2017-06-04T21:23:59.508Z', - 'value': '0.0420347312480917' - }, - { - 'time': '2017-06-04T21:24:59.508Z', - 'value': '0.0023175131665412706' - }, - { - 'time': '2017-06-04T21:25:59.508Z', - 'value': '0.002315870476190476' - }, - { - 'time': '2017-06-04T21:26:59.508Z', - 'value': '0.0025005961904761894' - }, - { - 'time': '2017-06-04T21:27:59.508Z', - 'value': '0.0024612605834341264' - }, - { - 'time': '2017-06-04T21:28:59.508Z', - 'value': '0.002313129398767631' - }, - { - 'time': '2017-06-04T21:29:59.508Z', - 'value': '0.002411067353663882' - }, - { - 'time': '2017-06-04T21:30:59.508Z', - 'value': '0.002577309263721303' - }, - { - 'time': '2017-06-04T21:31:59.508Z', - 'value': '0.00242688307730403' - }, - { - 'time': '2017-06-04T21:32:59.508Z', - 'value': '0.0024168360301330457' - }, - { - 'time': '2017-06-04T21:33:59.508Z', - 'value': '0.0020449528090743714' - }, - { - 'time': '2017-06-04T21:34:59.508Z', - 'value': '0.0019149619047619036' - }, - { - 'time': '2017-06-04T21:35:59.508Z', - 'value': '0.0024491714364625094' - }, - { - 'time': '2017-06-04T21:36:59.508Z', - 'value': '0.002728773131172677' - }, - { - 'time': '2017-06-04T21:37:59.508Z', - 'value': '0.0028439119047618997' - }, - { - 'time': '2017-06-04T21:38:59.508Z', - 'value': '0.0026307480952380917' - }, - { - 'time': '2017-06-04T21:39:59.508Z', - 'value': '0.0025024842620546446' - }, - { - 'time': '2017-06-04T21:40:59.508Z', - 'value': '0.002300662387260825' - }, - { - 'time': '2017-06-04T21:41:59.508Z', - 'value': '0.002052890924848337' - }, - { - 'time': '2017-06-04T21:42:59.508Z', - 'value': '0.0023711195238095275' - }, - { - 'time': '2017-06-04T21:43:59.508Z', - 'value': '0.002513477619047618' - }, - { - 'time': '2017-06-04T21:44:59.508Z', - 'value': '0.0023489776287844897' - }, - { - 'time': '2017-06-04T21:45:59.508Z', - 'value': '0.002542572310212481' - }, - { - 'time': '2017-06-04T21:46:59.508Z', - 'value': '0.0024579470671707952' - }, - { - 'time': '2017-06-04T21:47:59.508Z', - 'value': '0.0028725150236664403' - }, - { - 'time': '2017-06-04T21:48:59.508Z', - 'value': '0.0024356089105610525' - }, - { - 'time': '2017-06-04T21:49:59.508Z', - 'value': '0.002544015828269929' - }, - { - 'time': '2017-06-04T21:50:59.508Z', - 'value': '0.0029595013380824906' - }, - { - 'time': '2017-06-04T21:51:59.508Z', - 'value': '0.0023084015085858' - }, - { - 'time': '2017-06-04T21:52:59.508Z', - 'value': '0.0021070500000000083' - }, - { - 'time': '2017-06-04T21:53:59.508Z', - 'value': '0.0022950066191106617' - }, - { - 'time': '2017-06-04T21:54:59.508Z', - 'value': '0.002492719454470995' - }, - { - 'time': '2017-06-04T21:55:59.508Z', - 'value': '0.00244312761904762' - }, - { - 'time': '2017-06-04T21:56:59.508Z', - 'value': '0.0023495500000000028' - }, - { - 'time': '2017-06-04T21:57:59.508Z', - 'value': '0.0020597072353070005' - }, - { - 'time': '2017-06-04T21:58:59.508Z', - 'value': '0.0021482352044800866' - }, - { - 'time': '2017-06-04T21:59:59.508Z', - 'value': '0.002333490000000004' - }, - { - 'time': '2017-06-04T22:00:59.508Z', - 'value': '0.0025899442857142815' - }, - { - 'time': '2017-06-04T22:01:59.508Z', - 'value': '0.002430299999999999' - }, - { - 'time': '2017-06-04T22:02:59.508Z', - 'value': '0.0023550328092113476' - }, - { - 'time': '2017-06-04T22:03:59.508Z', - 'value': '0.0026521871636872793' - }, - { - 'time': '2017-06-04T22:04:59.508Z', - 'value': '0.0023080671428571398' - }, - { - 'time': '2017-06-04T22:05:59.508Z', - 'value': '0.0024108401032390896' - }, - { - 'time': '2017-06-04T22:06:59.508Z', - 'value': '0.002433249366678738' - }, - { - 'time': '2017-06-04T22:07:59.508Z', - 'value': '0.0023242202306688682' - }, - { - 'time': '2017-06-04T22:08:59.508Z', - 'value': '0.002388222857142859' - }, - { - 'time': '2017-06-04T22:09:59.508Z', - 'value': '0.002115974914046794' - }, - { - 'time': '2017-06-04T22:10:59.508Z', - 'value': '0.0025090043331269917' - }, - { - 'time': '2017-06-04T22:11:59.508Z', - 'value': '0.002445507057277277' - }, - { - 'time': '2017-06-04T22:12:59.508Z', - 'value': '0.0026348773751130976' - }, - { - 'time': '2017-06-04T22:13:59.508Z', - 'value': '0.0025616258583088104' - }, - { - 'time': '2017-06-04T22:14:59.508Z', - 'value': '0.0021544093415751505' - }, - { - 'time': '2017-06-04T22:15:59.508Z', - 'value': '0.002649394767668881' - }, - { - 'time': '2017-06-04T22:16:59.508Z', - 'value': '0.0024023332666685705' - }, - { - 'time': '2017-06-04T22:17:59.508Z', - 'value': '0.0025444105294235306' - }, - { - 'time': '2017-06-04T22:18:59.508Z', - 'value': '0.0027298872305772806' - }, - { - 'time': '2017-06-04T22:19:59.508Z', - 'value': '0.0022880104956379287' - }, - { - 'time': '2017-06-04T22:20:59.508Z', - 'value': '0.002473246666666661' - }, - { - 'time': '2017-06-04T22:21:59.508Z', - 'value': '0.002259948381935587' - }, - { - 'time': '2017-06-04T22:22:59.508Z', - 'value': '0.0025778470886268835' - }, - { - 'time': '2017-06-04T22:23:59.508Z', - 'value': '0.002246127910852894' - }, - { - 'time': '2017-06-04T22:24:59.508Z', - 'value': '0.0020697466666666758' - }, - { - 'time': '2017-06-04T22:25:59.508Z', - 'value': '0.00225859722473547' - }, - { - 'time': '2017-06-04T22:26:59.508Z', - 'value': '0.0026466728254554814' - }, - { - 'time': '2017-06-04T22:27:59.508Z', - 'value': '0.002151247619047619' - }, - { - 'time': '2017-06-04T22:28:59.508Z', - 'value': '0.002324161444543914' - }, - { - 'time': '2017-06-04T22:29:59.508Z', - 'value': '0.002476474313796452' - }, - { - 'time': '2017-06-04T22:30:59.508Z', - 'value': '0.0023922184232080517' - }, - { - 'time': '2017-06-04T22:31:59.508Z', - 'value': '0.0025094934237468933' - }, - { - 'time': '2017-06-04T22:32:59.508Z', - 'value': '0.0025665311098200883' - }, - { - 'time': '2017-06-04T22:33:59.508Z', - 'value': '0.0024154900681661374' - }, - { - 'time': '2017-06-04T22:34:59.508Z', - 'value': '0.0023267450166192037' - }, - { - 'time': '2017-06-04T22:35:59.508Z', - 'value': '0.002156521904761904' - }, - { - 'time': '2017-06-04T22:36:59.508Z', - 'value': '0.0025474356898637007' - }, - { - 'time': '2017-06-04T22:37:59.508Z', - 'value': '0.0025989409624670233' - }, - { - 'time': '2017-06-04T22:38:59.508Z', - 'value': '0.002348336664762987' - }, - { - 'time': '2017-06-04T22:39:59.508Z', - 'value': '0.002665888246554726' - }, - { - 'time': '2017-06-04T22:40:59.508Z', - 'value': '0.002652684787474174' - }, - { - 'time': '2017-06-04T22:41:59.508Z', - 'value': '0.002472620430865355' - }, - { - 'time': '2017-06-04T22:42:59.508Z', - 'value': '0.0020616469210110247' - }, - { - 'time': '2017-06-04T22:43:59.508Z', - 'value': '0.0022434546372311934' - }, - { - 'time': '2017-06-04T22:44:59.508Z', - 'value': '0.0024469386784827982' - }, - { - 'time': '2017-06-04T22:45:59.508Z', - 'value': '0.0026192823809523787' - }, - { - 'time': '2017-06-04T22:46:59.508Z', - 'value': '0.003451999542852798' - }, - { - 'time': '2017-06-04T22:47:59.508Z', - 'value': '0.0031780314285714288' - }, - { - 'time': '2017-06-04T22:48:59.508Z', - 'value': '0.0024403352380952415' - }, - { - 'time': '2017-06-04T22:49:59.508Z', - 'value': '0.001998824761904764' - }, - { - 'time': '2017-06-04T22:50:59.508Z', - 'value': '0.0023792404761904806' - }, - { - 'time': '2017-06-04T22:51:59.508Z', - 'value': '0.002725906190476185' - }, - { - 'time': '2017-06-04T22:52:59.508Z', - 'value': '0.0020989528671155624' - }, - { - 'time': '2017-06-04T22:53:59.508Z', - 'value': '0.00228808226745016' - }, - { - 'time': '2017-06-04T22:54:59.508Z', - 'value': '0.0019860807413192147' - }, - { - 'time': '2017-06-04T22:55:59.508Z', - 'value': '0.0022698085714285897' - }, - { - 'time': '2017-06-04T22:56:59.508Z', - 'value': '0.0022839098467604415' - }, - { - 'time': '2017-06-04T22:57:59.508Z', - 'value': '0.002531114761904749' - }, - { - 'time': '2017-06-04T22:58:59.508Z', - 'value': '0.0028941072550999016' - }, - { - 'time': '2017-06-04T22:59:59.508Z', - 'value': '0.002547169523809506' - }, - { - 'time': '2017-06-04T23:00:59.508Z', - 'value': '0.0024062999999999958' - }, - { - 'time': '2017-06-04T23:01:59.508Z', - 'value': '0.0026939518471604386' - }, - { - 'time': '2017-06-04T23:02:59.508Z', - 'value': '0.002362901428571429' - }, - { - 'time': '2017-06-04T23:03:59.508Z', - 'value': '0.002663927142857154' - }, - { - 'time': '2017-06-04T23:04:59.508Z', - 'value': '0.0026173314285714354' - }, - { - 'time': '2017-06-04T23:05:59.508Z', - 'value': '0.002326527366406044' - }, - { - 'time': '2017-06-04T23:06:59.508Z', - 'value': '0.002035313809523809' - }, - { - 'time': '2017-06-04T23:07:59.508Z', - 'value': '0.002421447414786533' - }, - { - 'time': '2017-06-04T23:08:59.508Z', - 'value': '0.002898313809523804' - }, - { - 'time': '2017-06-04T23:09:59.508Z', - 'value': '0.002544891856112907' - }, - { - 'time': '2017-06-04T23:10:59.508Z', - 'value': '0.002290625356938882' - }, - { - 'time': '2017-06-04T23:11:59.508Z', - 'value': '0.002483028095238096' - }, - { - 'time': '2017-06-04T23:12:59.508Z', - 'value': '0.0023396832350784237' - }, - { - 'time': '2017-06-04T23:13:59.508Z', - 'value': '0.002085529248176153' - }, - { - 'time': '2017-06-04T23:14:59.508Z', - 'value': '0.0022417815068428012' - }, - { - 'time': '2017-06-04T23:15:59.508Z', - 'value': '0.002660293333333341' - }, - { - 'time': '2017-06-04T23:16:59.508Z', - 'value': '0.0029845149093818226' - }, - { - 'time': '2017-06-04T23:17:59.508Z', - 'value': '0.0027716655079475464' - }, - { - 'time': '2017-06-04T23:18:59.508Z', - 'value': '0.0025217708908741128' - }, - { - 'time': '2017-06-04T23:19:59.508Z', - 'value': '0.0025811235131094055' - }, - { - 'time': '2017-06-04T23:20:59.508Z', - 'value': '0.002209904761904762' - }, - { - 'time': '2017-06-04T23:21:59.508Z', - 'value': '0.0025053322926383344' - }, - { - 'time': '2017-06-04T23:22:59.508Z', - 'value': '0.002350917636526411' - }, - { - 'time': '2017-06-04T23:23:59.508Z', - 'value': '0.0018477500000000078' - }, - { - 'time': '2017-06-04T23:24:59.508Z', - 'value': '0.002427629523809527' - }, - { - 'time': '2017-06-04T23:25:59.508Z', - 'value': '0.0019305498147601655' - }, - { - 'time': '2017-06-04T23:26:59.508Z', - 'value': '0.002097250000000006' - }, - { - 'time': '2017-06-04T23:27:59.508Z', - 'value': '0.002675020952780041' - }, - { - 'time': '2017-06-04T23:28:59.508Z', - 'value': '0.0023142214285714374' - }, - { - 'time': '2017-06-04T23:29:59.508Z', - 'value': '0.0023644723809523737' - }, - { - 'time': '2017-06-04T23:30:59.508Z', - 'value': '0.002108696190476198' - }, - { - 'time': '2017-06-04T23:31:59.508Z', - 'value': '0.0019918289697997194' - }, - { - 'time': '2017-06-04T23:32:59.508Z', - 'value': '0.001583584285714283' - }, - { - 'time': '2017-06-04T23:33:59.508Z', - 'value': '0.002073770226383112' - }, - { - 'time': '2017-06-04T23:34:59.508Z', - 'value': '0.0025877664234966818' - }, - { - 'time': '2017-06-04T23:35:59.508Z', - 'value': '0.0021138238095238147' - }, - { - 'time': '2017-06-04T23:36:59.508Z', - 'value': '0.0022140838095238303' - }, - { - 'time': '2017-06-04T23:37:59.508Z', - 'value': '0.0018592674425248847' - }, - { - 'time': '2017-06-04T23:38:59.508Z', - 'value': '0.0020461969533657016' - }, - { - 'time': '2017-06-04T23:39:59.508Z', - 'value': '0.0021593628571428543' - }, - { - 'time': '2017-06-04T23:40:59.508Z', - 'value': '0.0024330682564928188' - }, - { - 'time': '2017-06-04T23:41:59.508Z', - 'value': '0.0021501804779093174' - }, - { - 'time': '2017-06-04T23:42:59.508Z', - 'value': '0.0025787493928397945' - }, - { - 'time': '2017-06-04T23:43:59.508Z', - 'value': '0.002593657082448396' - }, - { - 'time': '2017-06-04T23:44:59.508Z', - 'value': '0.0021316752380952306' - }, - { - 'time': '2017-06-04T23:45:59.508Z', - 'value': '0.0026972905019952086' - }, - { - 'time': '2017-06-04T23:46:59.508Z', - 'value': '0.002580250764292983' - }, - { - 'time': '2017-06-04T23:47:59.508Z', - 'value': '0.00227103000000001' - }, - { - 'time': '2017-06-04T23:48:59.508Z', - 'value': '0.0023678515647321146' - }, - { - 'time': '2017-06-04T23:49:59.508Z', - 'value': '0.002371472857142866' - }, - { - 'time': '2017-06-04T23:50:59.508Z', - 'value': '0.0026181353688500978' - }, - { - 'time': '2017-06-04T23:51:59.508Z', - 'value': '0.0025609667711121217' - }, - { - 'time': '2017-06-04T23:52:59.508Z', - 'value': '0.0027145308139922557' - }, - { - 'time': '2017-06-04T23:53:59.508Z', - 'value': '0.0024249397613310512' - }, - { - 'time': '2017-06-04T23:54:59.508Z', - 'value': '0.002399907142857147' - }, - { - 'time': '2017-06-04T23:55:59.508Z', - 'value': '0.0024753357142857195' - }, - { - 'time': '2017-06-04T23:56:59.508Z', - 'value': '0.0026179149325231575' - }, - { - 'time': '2017-06-04T23:57:59.508Z', - 'value': '0.0024261340368186956' - }, - { - 'time': '2017-06-04T23:58:59.508Z', - 'value': '0.0021061071428571517' - }, - { - 'time': '2017-06-04T23:59:59.508Z', - 'value': '0.0024033971105037015' - }, - { - 'time': '2017-06-05T00:00:59.508Z', - 'value': '0.0028287676190475956' - }, - { - 'time': '2017-06-05T00:01:59.508Z', - 'value': '0.002499719050294778' - }, - { - 'time': '2017-06-05T00:02:59.508Z', - 'value': '0.0026726102153353856' - }, - { - 'time': '2017-06-05T00:03:59.508Z', - 'value': '0.00262582619047618' - }, - { - 'time': '2017-06-05T00:04:59.508Z', - 'value': '0.002280473147363316' - }, - { - 'time': '2017-06-05T00:05:59.508Z', - 'value': '0.002095581470652675' - }, - { - 'time': '2017-06-05T00:06:59.508Z', - 'value': '0.002270768490828408' - }, - { - 'time': '2017-06-05T00:07:59.508Z', - 'value': '0.002728577415023017' - }, - { - 'time': '2017-06-05T00:08:59.508Z', - 'value': '0.002652512857142863' - }, - { - 'time': '2017-06-05T00:09:59.508Z', - 'value': '0.0022781033924455674' - }, - { - 'time': '2017-06-05T00:10:59.508Z', - 'value': '0.0025345038095238234' - }, - { - 'time': '2017-06-05T00:11:59.508Z', - 'value': '0.002376050020000397' - }, - { - 'time': '2017-06-05T00:12:59.508Z', - 'value': '0.002455068143506122' - }, - { - 'time': '2017-06-05T00:13:59.508Z', - 'value': '0.002826705714285719' - }, - { - 'time': '2017-06-05T00:14:59.508Z', - 'value': '0.002343833692070314' - }, - { - 'time': '2017-06-05T00:15:59.508Z', - 'value': '0.00264853297122164' - }, - { - 'time': '2017-06-05T00:16:59.508Z', - 'value': '0.0027656335117426257' - }, - { - 'time': '2017-06-05T00:17:59.508Z', - 'value': '0.0025896543842439564' - }, - { - 'time': '2017-06-05T00:18:59.508Z', - 'value': '0.002180053237081201' - }, - { - 'time': '2017-06-05T00:19:59.508Z', - 'value': '0.002475245002333342' - }, - { - 'time': '2017-06-05T00:20:59.508Z', - 'value': '0.0027559767805101065' - }, - { - 'time': '2017-06-05T00:21:59.508Z', - 'value': '0.0022294836141296607' - }, - { - 'time': '2017-06-05T00:22:59.508Z', - 'value': '0.0021383590476190643' - }, - { - 'time': '2017-06-05T00:23:59.508Z', - 'value': '0.002085417956361494' - }, - { - 'time': '2017-06-05T00:24:59.508Z', - 'value': '0.0024140319047619013' - }, - { - 'time': '2017-06-05T00:25:59.508Z', - 'value': '0.0024513114285714304' - }, - { - 'time': '2017-06-05T00:26:59.508Z', - 'value': '0.0026932152380952446' - }, - { - 'time': '2017-06-05T00:27:59.508Z', - 'value': '0.0022656844350898517' - }, - { - 'time': '2017-06-05T00:28:59.508Z', - 'value': '0.0024483785714285704' - }, - { - 'time': '2017-06-05T00:29:59.508Z', - 'value': '0.002559505804817207' - }, - { - 'time': '2017-06-05T00:30:59.508Z', - 'value': '0.0019485681088751649' - }, - { - 'time': '2017-06-05T00:31:59.508Z', - 'value': '0.00228367984456996' - }, - { - 'time': '2017-06-05T00:32:59.508Z', - 'value': '0.002522149047619049' - }, - { - 'time': '2017-06-05T00:33:59.508Z', - 'value': '0.0026860117715406737' - }, - { - 'time': '2017-06-05T00:34:59.508Z', - 'value': '0.002679669523809523' - }, - { - 'time': '2017-06-05T00:35:59.508Z', - 'value': '0.0022201920970675937' - }, - { - 'time': '2017-06-05T00:36:59.508Z', - 'value': '0.0022917647619047615' - }, - { - 'time': '2017-06-05T00:37:59.508Z', - 'value': '0.0021774059294673576' - }, - { - 'time': '2017-06-05T00:38:59.508Z', - 'value': '0.0024637766666666763' - }, - { - 'time': '2017-06-05T00:39:59.508Z', - 'value': '0.002470468290174195' - }, - { - 'time': '2017-06-05T00:40:59.508Z', - 'value': '0.0022188616082057812' - }, - { - 'time': '2017-06-05T00:41:59.508Z', - 'value': '0.002421840744373875' - }, - { - 'time': '2017-06-05T00:42:59.508Z', - 'value': '0.0023918266666666547' - }, - { - 'time': '2017-06-05T00:43:59.508Z', - 'value': '0.002195743809523809' - }, - { - 'time': '2017-06-05T00:44:59.508Z', - 'value': '0.0025514828571428687' - }, - { - 'time': '2017-06-05T00:45:59.508Z', - 'value': '0.0027981709349612694' - }, - { - 'time': '2017-06-05T00:46:59.508Z', - 'value': '0.002557977142857146' - }, - { - 'time': '2017-06-05T00:47:59.508Z', - 'value': '0.002213244285714286' - }, - { - 'time': '2017-06-05T00:48:59.508Z', - 'value': '0.0025706738095238046' - }, - { - 'time': '2017-06-05T00:49:59.508Z', - 'value': '0.002210976666666671' - }, - { - 'time': '2017-06-05T00:50:59.508Z', - 'value': '0.002055377091646749' - }, - { - 'time': '2017-06-05T00:51:59.508Z', - 'value': '0.002308368095238119' - }, - { - 'time': '2017-06-05T00:52:59.508Z', - 'value': '0.0024687939885141615' - }, - { - 'time': '2017-06-05T00:53:59.508Z', - 'value': '0.002563018571428578' - }, - { - 'time': '2017-06-05T00:54:59.508Z', - 'value': '0.00240563291078959' - } - ] - } +export const singleRowMetricsMultipleSeries = [ + { + 'title': 'Multiple Time Series', + 'weight': 1, + 'y_label': 'Request Rates', + 'queries': [ + { + 'query_range': 'sum(rate(nginx_responses_total{environment="production"}[2m])) by (status_code)', + 'label': 'Requests', + 'unit': 'Req/sec', + 'result': [ + { + 'metric': { + 'status_code': '1xx' + }, + 'values': [ + { + 'time': '2017-08-27T11:01:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:02:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:03:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:04:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:05:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:06:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:07:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:08:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:09:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:10:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:11:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:12:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:13:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:14:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:15:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:16:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:17:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:18:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:19:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:20:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:21:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:22:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:23:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:24:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:25:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:26:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:27:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:28:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:29:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:30:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:31:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:32:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:33:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:34:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:35:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:36:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:37:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:38:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:39:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:40:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:41:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:42:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:43:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:44:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:45:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:46:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:47:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:48:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:49:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:50:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:51:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:52:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:53:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:54:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:55:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:56:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:57:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:58:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T11:59:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:00:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:01:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:02:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:03:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:04:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:05:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:06:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:07:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:08:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:09:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:10:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:11:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:12:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:13:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:14:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:15:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:16:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:17:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:18:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:19:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:20:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:21:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:22:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:23:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:24:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:25:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:26:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:27:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:28:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:29:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:30:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:31:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:32:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:33:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:34:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:35:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:36:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:37:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:38:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:39:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:40:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:41:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:42:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:43:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:44:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:45:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:46:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:47:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:48:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:49:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:50:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:51:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:52:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:53:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:54:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:55:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:56:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:57:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:58:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T12:59:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:00:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:01:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:02:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:03:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:04:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:05:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:06:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:07:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:08:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:09:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:10:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:11:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:12:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:13:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:14:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:15:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:16:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:17:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:18:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:19:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:20:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:21:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:22:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:23:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:24:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:25:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:26:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:27:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:28:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:29:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:30:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:31:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:32:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:33:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:34:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:35:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:36:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:37:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:38:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:39:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:40:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:41:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:42:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:43:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:44:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:45:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:46:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:47:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:48:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:49:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:50:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:51:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:52:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:53:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:54:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:55:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:56:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:57:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:58:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T13:59:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:00:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:01:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:02:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:03:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:04:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:05:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:06:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:07:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:08:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:09:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:10:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:11:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:12:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:13:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:14:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:15:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:16:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:17:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:18:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:19:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:20:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:21:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:22:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:23:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:24:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:25:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:26:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:27:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:28:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:29:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:30:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:31:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:32:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:33:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:34:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:35:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:36:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:37:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:38:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:39:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:40:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:41:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:42:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:43:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:44:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:45:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:46:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:47:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:48:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:49:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:50:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:51:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:52:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:53:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:54:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:55:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:56:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:57:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:58:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T14:59:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:00:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:01:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:02:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:03:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:04:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:05:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:06:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:07:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:08:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:09:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:10:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:11:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:12:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:13:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:14:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:15:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:16:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:17:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:18:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:19:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:20:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:21:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:22:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:23:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:24:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:25:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:26:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:27:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:28:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:29:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:30:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:31:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:32:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:33:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:34:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:35:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:36:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:37:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:38:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:39:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:40:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:41:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:42:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:43:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:44:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:45:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:46:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:47:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:48:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:49:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:50:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:51:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:52:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:53:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:54:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:55:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:56:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:57:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:58:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T15:59:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:00:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:01:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:02:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:03:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:04:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:05:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:06:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:07:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:08:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:09:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:10:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:11:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:12:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:13:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:14:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:15:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:16:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:17:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:18:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:19:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:20:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:21:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:22:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:23:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:24:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:25:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:26:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:27:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:28:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:29:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:30:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:31:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:32:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:33:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:34:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:35:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:36:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:37:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:38:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:39:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:40:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:41:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:42:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:43:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:44:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:45:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:46:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:47:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:48:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:49:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:50:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:51:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:52:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:53:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:54:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:55:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:56:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:57:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:58:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T16:59:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:00:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:01:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:02:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:03:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:04:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:05:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:06:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:07:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:08:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:09:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:10:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:11:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:12:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:13:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:14:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:15:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:16:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:17:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:18:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:19:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:20:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:21:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:22:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:23:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:24:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:25:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:26:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:27:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:28:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:29:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:30:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:31:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:32:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:33:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:34:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:35:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:36:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:37:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:38:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:39:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:40:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:41:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:42:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:43:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:44:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:45:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:46:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:47:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:48:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:49:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:50:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:51:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:52:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:53:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:54:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:55:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:56:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:57:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:58:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T17:59:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:00:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:01:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:02:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:03:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:04:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:05:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:06:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:07:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:08:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:09:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:10:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:11:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:12:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:13:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:14:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:15:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:16:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:17:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:18:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:19:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:20:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:21:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:22:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:23:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:24:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:25:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:26:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:27:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:28:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:29:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:30:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:31:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:32:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:33:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:34:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:35:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:36:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:37:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:38:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:39:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:40:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:41:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:42:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:43:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:44:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:45:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:46:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:47:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:48:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:49:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:50:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:51:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:52:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:53:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:54:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:55:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:56:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:57:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:58:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T18:59:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T19:00:51.462Z', + 'value': '0' + }, + { + 'time': '2017-08-27T19:01:51.462Z', + 'value': '0' + } + ] + }, + { + 'metric': { + 'status_code': '2xx' + }, + 'values': [ + { + 'time': '2017-08-27T11:01:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:02:51.462Z', + 'value': '1.2571428571428571' + }, + { + 'time': '2017-08-27T11:03:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:04:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:05:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T11:06:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:07:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T11:08:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:09:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:10:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:11:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:12:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:13:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:14:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T11:15:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:16:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T11:17:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:18:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T11:19:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T11:20:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T11:21:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T11:22:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:23:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:24:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:25:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T11:26:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:27:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:28:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:29:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:30:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:31:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:32:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:33:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:34:51.462Z', + 'value': '1.333320635041571' + }, + { + 'time': '2017-08-27T11:35:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:36:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:37:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:38:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:39:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T11:40:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:41:51.462Z', + 'value': '1.3333587306424883' + }, + { + 'time': '2017-08-27T11:42:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:43:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:44:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:45:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T11:46:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T11:47:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T11:48:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:49:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T11:50:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:51:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:52:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:53:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:54:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:55:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:56:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:57:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T11:58:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T11:59:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:00:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:01:51.462Z', + 'value': '1.3333460318669703' + }, + { + 'time': '2017-08-27T12:02:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:03:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:04:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:05:51.462Z', + 'value': '1.31427319739812' + }, + { + 'time': '2017-08-27T12:06:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:07:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:08:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:09:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:10:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:11:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:12:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:13:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:14:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:15:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:16:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:17:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:18:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:19:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:20:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:21:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:22:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:23:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:24:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:25:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:26:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:27:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:28:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:29:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:30:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:31:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:32:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:33:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:34:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:35:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:36:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:37:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:38:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:39:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:40:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:41:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:42:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:43:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:44:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:45:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:46:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:47:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:48:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:49:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:50:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:51:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:52:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:53:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:54:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T12:55:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:56:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:57:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T12:58:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T12:59:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T13:00:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T13:01:51.462Z', + 'value': '1.295225759754669' + }, + { + 'time': '2017-08-27T13:02:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:03:51.462Z', + 'value': '1.2952627669098458' + }, + { + 'time': '2017-08-27T13:04:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:05:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:06:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:07:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:08:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:09:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:10:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:11:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:12:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:13:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:14:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:15:51.462Z', + 'value': '1.2571428571428571' + }, + { + 'time': '2017-08-27T13:16:51.462Z', + 'value': '1.3333587306424883' + }, + { + 'time': '2017-08-27T13:17:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:18:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:19:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:20:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T13:21:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:22:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:23:51.462Z', + 'value': '1.276190476190476' + }, + { + 'time': '2017-08-27T13:24:51.462Z', + 'value': '1.2571428571428571' + }, + { + 'time': '2017-08-27T13:25:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T13:26:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:27:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T13:28:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:29:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:30:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:31:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:32:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T13:33:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:34:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:35:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T13:36:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:37:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:38:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:39:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:40:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:41:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:42:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:43:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:44:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:45:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:46:51.462Z', + 'value': '1.2571428571428571' + }, + { + 'time': '2017-08-27T13:47:51.462Z', + 'value': '1.276190476190476' + }, + { + 'time': '2017-08-27T13:48:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T13:49:51.462Z', + 'value': '1.295225759754669' + }, + { + 'time': '2017-08-27T13:50:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:51:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:52:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:53:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:54:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:55:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:56:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T13:57:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T13:58:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T13:59:51.462Z', + 'value': '1.295225759754669' + }, + { + 'time': '2017-08-27T14:00:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T14:01:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T14:02:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:03:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:04:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:05:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T14:06:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:07:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:08:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:09:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:10:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:11:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:12:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:13:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:14:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:15:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:16:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:17:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T14:18:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:19:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T14:20:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:21:51.462Z', + 'value': '1.3333079369916765' + }, + { + 'time': '2017-08-27T14:22:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:23:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:24:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:25:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T14:26:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:27:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T14:28:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:29:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:30:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T14:31:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:32:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:33:51.462Z', + 'value': '1.2571428571428571' + }, + { + 'time': '2017-08-27T14:34:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:35:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:36:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:37:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:38:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T14:39:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:40:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:41:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:42:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:43:51.462Z', + 'value': '1.276190476190476' + }, + { + 'time': '2017-08-27T14:44:51.462Z', + 'value': '1.2571428571428571' + }, + { + 'time': '2017-08-27T14:45:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:46:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:47:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:48:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:49:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:50:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:51:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T14:52:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:53:51.462Z', + 'value': '1.333320635041571' + }, + { + 'time': '2017-08-27T14:54:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:55:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T14:56:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:57:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T14:58:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T14:59:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T15:00:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:01:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:02:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:03:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:04:51.462Z', + 'value': '1.2571428571428571' + }, + { + 'time': '2017-08-27T15:05:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:06:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:07:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:08:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:09:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:10:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:11:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:12:51.462Z', + 'value': '1.31427319739812' + }, + { + 'time': '2017-08-27T15:13:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:14:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:15:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:16:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T15:17:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:18:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:19:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:20:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T15:21:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:22:51.462Z', + 'value': '1.3333460318669703' + }, + { + 'time': '2017-08-27T15:23:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:24:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:25:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:26:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:27:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:28:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:29:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:30:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:31:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T15:32:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:33:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T15:34:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:35:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T15:36:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:37:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:38:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T15:39:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:40:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:41:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:42:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:43:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:44:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:45:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:46:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:47:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:48:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:49:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T15:50:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:51:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:52:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:53:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:54:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:55:51.462Z', + 'value': '1.3333587306424883' + }, + { + 'time': '2017-08-27T15:56:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T15:57:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:58:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T15:59:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:00:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:01:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:02:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:03:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:04:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:05:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:06:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:07:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:08:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:09:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:10:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:11:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:12:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:13:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:14:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:15:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:16:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:17:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:18:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:19:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:20:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:21:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:22:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:23:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:24:51.462Z', + 'value': '1.295225759754669' + }, + { + 'time': '2017-08-27T16:25:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:26:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:27:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:28:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:29:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:30:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:31:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:32:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:33:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:34:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:35:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:36:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:37:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:38:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:39:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:40:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:41:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:42:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:43:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:44:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:45:51.462Z', + 'value': '1.3142982314117277' + }, + { + 'time': '2017-08-27T16:46:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:47:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:48:51.462Z', + 'value': '1.333320635041571' + }, + { + 'time': '2017-08-27T16:49:51.462Z', + 'value': '1.31427319739812' + }, + { + 'time': '2017-08-27T16:50:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:51:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:52:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:53:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:54:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:55:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T16:56:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:57:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T16:58:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T16:59:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:00:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:01:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:02:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:03:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:04:51.462Z', + 'value': '1.2952504309564854' + }, + { + 'time': '2017-08-27T17:05:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T17:06:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:07:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T17:08:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T17:09:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:10:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:11:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:12:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:13:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:14:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:15:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:16:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:17:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:18:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:19:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:20:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:21:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:22:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:23:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:24:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T17:25:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:26:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:27:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:28:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:29:51.462Z', + 'value': '1.295225759754669' + }, + { + 'time': '2017-08-27T17:30:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:31:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:32:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:33:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:34:51.462Z', + 'value': '1.295225759754669' + }, + { + 'time': '2017-08-27T17:35:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:36:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T17:37:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:38:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:39:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:40:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:41:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:42:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:43:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:44:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T17:45:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:46:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:47:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:48:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T17:49:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:50:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T17:51:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:52:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:53:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:54:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:55:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T17:56:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:57:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T17:58:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T17:59:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T18:00:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:01:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:02:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:03:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:04:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:05:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:06:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:07:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:08:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:09:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:10:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:11:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:12:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T18:13:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:14:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:15:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:16:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:17:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:18:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:19:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:20:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:21:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:22:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:23:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:24:51.462Z', + 'value': '1.2571428571428571' + }, + { + 'time': '2017-08-27T18:25:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:26:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:27:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:28:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:29:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:30:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:31:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:32:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:33:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:34:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:35:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:36:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:37:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T18:38:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:39:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:40:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:41:51.462Z', + 'value': '1.580952380952381' + }, + { + 'time': '2017-08-27T18:42:51.462Z', + 'value': '1.7333333333333334' + }, + { + 'time': '2017-08-27T18:43:51.462Z', + 'value': '2.057142857142857' + }, + { + 'time': '2017-08-27T18:44:51.462Z', + 'value': '2.1904761904761902' + }, + { + 'time': '2017-08-27T18:45:51.462Z', + 'value': '1.8285714285714287' + }, + { + 'time': '2017-08-27T18:46:51.462Z', + 'value': '2.1142857142857143' + }, + { + 'time': '2017-08-27T18:47:51.462Z', + 'value': '1.619047619047619' + }, + { + 'time': '2017-08-27T18:48:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:49:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:50:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T18:51:51.462Z', + 'value': '1.2952504309564854' + }, + { + 'time': '2017-08-27T18:52:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:53:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:54:51.462Z', + 'value': '1.3333333333333333' + }, + { + 'time': '2017-08-27T18:55:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:56:51.462Z', + 'value': '1.314285714285714' + }, + { + 'time': '2017-08-27T18:57:51.462Z', + 'value': '1.295238095238095' + }, + { + 'time': '2017-08-27T18:58:51.462Z', + 'value': '1.7142857142857142' + }, + { + 'time': '2017-08-27T18:59:51.462Z', + 'value': '1.7333333333333334' + }, + { + 'time': '2017-08-27T19:00:51.462Z', + 'value': '1.3904761904761904' + }, + { + 'time': '2017-08-27T19:01:51.462Z', + 'value': '1.5047619047619047' + } + ] + }, + ] + } ] - } - ] - }, - { - 'title': 'Memory usage', - 'weight': 1, - 'y_label': 'Values', - 'queries': [ - { - 'query_range': 'avg(container_memory_usage_bytes{%{environment_filter}}) / 2^20', - 'label': 'Container memory', - 'unit': 'MiB', - 'result': [ - { - 'metric': { + }, + { + 'title': 'Throughput', + 'weight': 1, + 'y_label': 'Requests / Sec', + 'queries': [ + { + 'query_range': 'sum(rate(nginx_requests_total{server_zone!=\'*\', server_zone!=\'_\', container_name!=\'POD\',environment=\'production\'}[2m]))', + 'label': 'Total', + 'unit': 'req / sec', + 'result': [ + { + 'metric': { - }, - 'values': [ - { - 'time': '2017-06-04T21:22:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:23:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:24:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:25:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:26:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:27:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:28:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:29:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:30:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:31:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:32:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:33:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:34:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:35:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:36:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:37:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:38:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:39:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:40:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:41:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:42:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:43:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:44:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:45:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:46:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:47:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:48:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:49:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:50:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:51:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:52:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:53:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:54:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:55:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:56:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:57:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:58:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T21:59:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:00:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:01:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:02:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:03:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:04:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:05:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:06:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:07:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:08:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:09:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:10:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:11:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:12:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:13:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:14:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:15:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:16:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:17:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:18:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:19:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:20:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:21:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:22:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:23:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:24:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:25:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:26:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:27:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:28:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:29:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:30:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:31:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:32:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:33:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:34:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:35:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:36:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:37:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:38:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:39:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:40:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:41:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:42:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:43:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:44:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:45:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:46:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:47:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:48:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:49:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:50:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:51:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:52:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:53:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:54:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:55:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:56:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:57:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:58:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T22:59:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:00:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:01:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:02:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:03:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:04:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:05:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:06:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:07:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:08:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:09:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:10:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:11:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:12:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:13:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:14:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:15:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:16:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:17:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:18:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:19:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:20:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:21:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:22:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:23:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:24:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:25:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:26:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:27:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:28:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:29:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:30:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:31:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:32:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:33:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:34:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:35:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:36:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:37:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:38:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:39:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:40:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:41:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:42:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:43:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:44:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:45:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:46:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:47:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:48:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:49:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:50:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:51:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:52:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:53:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:54:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:55:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:56:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:57:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:58:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-04T23:59:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:00:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:01:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:02:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:03:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:04:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:05:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:06:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:07:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:08:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:09:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:10:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:11:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:12:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:13:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:14:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:15:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:16:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:17:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:18:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:19:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:20:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:21:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:22:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:23:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:24:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:25:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:26:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:27:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:28:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:29:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:30:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:31:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:32:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:33:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:34:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:35:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:36:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:37:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:38:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:39:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:40:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:41:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:42:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:43:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:44:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:45:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:46:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:47:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:48:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:49:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:50:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:51:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:52:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:53:59.508Z', - 'value': '15.0859375' - }, - { - 'time': '2017-06-05T00:54:59.508Z', - 'value': '15.0859375' - } - ] - } + }, + 'values': [ + { + 'time': '2017-08-27T11:01:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:02:51.462Z', + 'value': '0.45714285714285713' + }, + { + 'time': '2017-08-27T11:03:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:04:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:05:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T11:06:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:07:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T11:08:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:09:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:10:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:11:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:12:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:13:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:14:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T11:15:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:16:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T11:17:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:18:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T11:19:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T11:20:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T11:21:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T11:22:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:23:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:24:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:25:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T11:26:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:27:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:28:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:29:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:30:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:31:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:32:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:33:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:34:51.462Z', + 'value': '0.4952333787297264' + }, + { + 'time': '2017-08-27T11:35:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:36:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:37:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:38:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:39:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T11:40:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:41:51.462Z', + 'value': '0.49524752852435283' + }, + { + 'time': '2017-08-27T11:42:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:43:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:44:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:45:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T11:46:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T11:47:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T11:48:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:49:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T11:50:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:51:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:52:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:53:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:54:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:55:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:56:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:57:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T11:58:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T11:59:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:00:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:01:51.462Z', + 'value': '0.49524281183630325' + }, + { + 'time': '2017-08-27T12:02:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:03:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:04:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:05:51.462Z', + 'value': '0.4857096599080009' + }, + { + 'time': '2017-08-27T12:06:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:07:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:08:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:09:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:10:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:11:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:12:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:13:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:14:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:15:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:16:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:17:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:18:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:19:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:20:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:21:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:22:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:23:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:24:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:25:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:26:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:27:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:28:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:29:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:30:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:31:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:32:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:33:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:34:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:35:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:36:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:37:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:38:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:39:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:40:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:41:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:42:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:43:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:44:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:45:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:46:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:47:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:48:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:49:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:50:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:51:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:52:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:53:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:54:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T12:55:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:56:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:57:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T12:58:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T12:59:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T13:00:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T13:01:51.462Z', + 'value': '0.4761859410862754' + }, + { + 'time': '2017-08-27T13:02:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:03:51.462Z', + 'value': '0.4761995466580315' + }, + { + 'time': '2017-08-27T13:04:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:05:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:06:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:07:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:08:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:09:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:10:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:11:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:12:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:13:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:14:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:15:51.462Z', + 'value': '0.45714285714285713' + }, + { + 'time': '2017-08-27T13:16:51.462Z', + 'value': '0.49524752852435283' + }, + { + 'time': '2017-08-27T13:17:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:18:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:19:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:20:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T13:21:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:22:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:23:51.462Z', + 'value': '0.4666666666666667' + }, + { + 'time': '2017-08-27T13:24:51.462Z', + 'value': '0.45714285714285713' + }, + { + 'time': '2017-08-27T13:25:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T13:26:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:27:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T13:28:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:29:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:30:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:31:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:32:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T13:33:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:34:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:35:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T13:36:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:37:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:38:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:39:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:40:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:41:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:42:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:43:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:44:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:45:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:46:51.462Z', + 'value': '0.45714285714285713' + }, + { + 'time': '2017-08-27T13:47:51.462Z', + 'value': '0.4666666666666667' + }, + { + 'time': '2017-08-27T13:48:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T13:49:51.462Z', + 'value': '0.4761859410862754' + }, + { + 'time': '2017-08-27T13:50:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:51:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:52:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:53:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:54:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:55:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:56:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T13:57:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T13:58:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T13:59:51.462Z', + 'value': '0.4761859410862754' + }, + { + 'time': '2017-08-27T14:00:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T14:01:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T14:02:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:03:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:04:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:05:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T14:06:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:07:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:08:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:09:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:10:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:11:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:12:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:13:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:14:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:15:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:16:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:17:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T14:18:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:19:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T14:20:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:21:51.462Z', + 'value': '0.4952286623111941' + }, + { + 'time': '2017-08-27T14:22:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:23:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:24:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:25:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T14:26:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:27:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T14:28:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:29:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:30:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T14:31:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:32:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:33:51.462Z', + 'value': '0.45714285714285713' + }, + { + 'time': '2017-08-27T14:34:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:35:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:36:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:37:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:38:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T14:39:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:40:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:41:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:42:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:43:51.462Z', + 'value': '0.4666666666666667' + }, + { + 'time': '2017-08-27T14:44:51.462Z', + 'value': '0.45714285714285713' + }, + { + 'time': '2017-08-27T14:45:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:46:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:47:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:48:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:49:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:50:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:51:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T14:52:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:53:51.462Z', + 'value': '0.4952333787297264' + }, + { + 'time': '2017-08-27T14:54:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:55:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T14:56:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:57:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T14:58:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T14:59:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T15:00:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:01:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:02:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:03:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:04:51.462Z', + 'value': '0.45714285714285713' + }, + { + 'time': '2017-08-27T15:05:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:06:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:07:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:08:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:09:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:10:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:11:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:12:51.462Z', + 'value': '0.4857096599080009' + }, + { + 'time': '2017-08-27T15:13:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:14:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:15:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:16:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T15:17:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:18:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:19:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:20:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T15:21:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:22:51.462Z', + 'value': '0.49524281183630325' + }, + { + 'time': '2017-08-27T15:23:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:24:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:25:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:26:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:27:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:28:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:29:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:30:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:31:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T15:32:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:33:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T15:34:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:35:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T15:36:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:37:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:38:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T15:39:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:40:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:41:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:42:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:43:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:44:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:45:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:46:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:47:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:48:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:49:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T15:50:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:51:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:52:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:53:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:54:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:55:51.462Z', + 'value': '0.49524752852435283' + }, + { + 'time': '2017-08-27T15:56:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T15:57:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:58:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T15:59:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:00:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:01:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:02:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:03:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:04:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:05:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:06:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:07:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:08:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:09:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:10:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:11:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:12:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:13:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:14:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:15:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:16:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:17:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:18:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:19:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:20:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:21:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:22:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:23:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:24:51.462Z', + 'value': '0.4761859410862754' + }, + { + 'time': '2017-08-27T16:25:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:26:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:27:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:28:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:29:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:30:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:31:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:32:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:33:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:34:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:35:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:36:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:37:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:38:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:39:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:40:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:41:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:42:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:43:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:44:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:45:51.462Z', + 'value': '0.485718911608682' + }, + { + 'time': '2017-08-27T16:46:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:47:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:48:51.462Z', + 'value': '0.4952333787297264' + }, + { + 'time': '2017-08-27T16:49:51.462Z', + 'value': '0.4857096599080009' + }, + { + 'time': '2017-08-27T16:50:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:51:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:52:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:53:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:54:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:55:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T16:56:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:57:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T16:58:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T16:59:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:00:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:01:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:02:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:03:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:04:51.462Z', + 'value': '0.47619501138106085' + }, + { + 'time': '2017-08-27T17:05:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T17:06:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:07:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T17:08:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T17:09:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:10:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:11:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:12:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:13:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:14:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:15:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:16:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:17:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:18:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:19:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:20:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:21:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:22:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:23:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:24:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T17:25:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:26:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:27:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:28:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:29:51.462Z', + 'value': '0.4761859410862754' + }, + { + 'time': '2017-08-27T17:30:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:31:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:32:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:33:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:34:51.462Z', + 'value': '0.4761859410862754' + }, + { + 'time': '2017-08-27T17:35:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:36:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T17:37:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:38:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:39:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:40:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:41:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:42:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:43:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:44:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T17:45:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:46:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:47:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:48:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T17:49:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:50:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T17:51:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:52:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:53:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:54:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:55:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T17:56:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:57:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T17:58:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T17:59:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T18:00:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:01:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:02:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:03:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:04:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:05:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:06:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:07:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:08:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:09:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:10:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:11:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:12:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T18:13:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:14:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:15:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:16:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:17:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:18:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:19:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:20:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:21:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:22:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:23:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:24:51.462Z', + 'value': '0.45714285714285713' + }, + { + 'time': '2017-08-27T18:25:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:26:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:27:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:28:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:29:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:30:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:31:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:32:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:33:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:34:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:35:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:36:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:37:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T18:38:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:39:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:40:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:41:51.462Z', + 'value': '0.6190476190476191' + }, + { + 'time': '2017-08-27T18:42:51.462Z', + 'value': '0.6952380952380952' + }, + { + 'time': '2017-08-27T18:43:51.462Z', + 'value': '0.857142857142857' + }, + { + 'time': '2017-08-27T18:44:51.462Z', + 'value': '0.9238095238095239' + }, + { + 'time': '2017-08-27T18:45:51.462Z', + 'value': '0.7428571428571429' + }, + { + 'time': '2017-08-27T18:46:51.462Z', + 'value': '0.8857142857142857' + }, + { + 'time': '2017-08-27T18:47:51.462Z', + 'value': '0.638095238095238' + }, + { + 'time': '2017-08-27T18:48:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:49:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:50:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T18:51:51.462Z', + 'value': '0.47619501138106085' + }, + { + 'time': '2017-08-27T18:52:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:53:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:54:51.462Z', + 'value': '0.4952380952380952' + }, + { + 'time': '2017-08-27T18:55:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:56:51.462Z', + 'value': '0.4857142857142857' + }, + { + 'time': '2017-08-27T18:57:51.462Z', + 'value': '0.47619047619047616' + }, + { + 'time': '2017-08-27T18:58:51.462Z', + 'value': '0.6857142857142856' + }, + { + 'time': '2017-08-27T18:59:51.462Z', + 'value': '0.6952380952380952' + }, + { + 'time': '2017-08-27T19:00:51.462Z', + 'value': '0.5238095238095237' + }, + { + 'time': '2017-08-27T19:01:51.462Z', + 'value': '0.5904761904761905' + } + ] + } + ] + } ] - } - ] - } + } ]; +export function convertDatesMultipleSeries(multipleSeries) { + const convertedMultiple = multipleSeries; + multipleSeries.forEach((column, index) => { + let convertedResult = []; + convertedResult = column.queries[0].result.map((resultObj) => { + const convertedMetrics = {}; + convertedMetrics.values = resultObj.values.map(val => ({ + time: new Date(val.time), + value: val.value, + })); + convertedMetrics.metric = resultObj.metric; + return convertedMetrics; + }); + convertedMultiple[index].queries[0].result = convertedResult; + }); + return convertedMultiple; +} + export function MonitorMockInterceptor(request, next) { const body = responseMockData[request.method.toUpperCase()][request.url]; diff --git a/spec/javascripts/monitoring/monitoring_paths_spec.js b/spec/javascripts/monitoring/monitoring_paths_spec.js new file mode 100644 index 00000000000..d39db945e17 --- /dev/null +++ b/spec/javascripts/monitoring/monitoring_paths_spec.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import MonitoringPaths from '~/monitoring/components/monitoring_paths.vue'; +import createTimeSeries from '~/monitoring/utils/multiple_time_series'; +import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from './mock_data'; + +const createComponent = (propsData) => { + const Component = Vue.extend(MonitoringPaths); + + return new Component({ + propsData, + }).$mount(); +}; + +const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); + +const timeSeries = createTimeSeries(convertedMetrics[0].queries[0].result, 428, 272, 120); + +describe('Monitoring Paths', () => { + it('renders two paths to represent a line and the area underneath it', () => { + const component = createComponent({ + generatedLinePath: timeSeries[0].linePath, + generatedAreaPath: timeSeries[0].areaPath, + lineColor: '#ccc', + areaColor: '#fff', + }); + const metricArea = component.$el.querySelector('.metric-area'); + const metricLine = component.$el.querySelector('.metric-line'); + + expect(metricArea.getAttribute('fill')).toBe('#fff'); + expect(metricArea.getAttribute('d')).toBe(timeSeries[0].areaPath); + expect(metricLine.getAttribute('stroke')).toBe('#ccc'); + expect(metricLine.getAttribute('d')).toBe(timeSeries[0].linePath); + }); +}); diff --git a/spec/javascripts/monitoring/utils/multiple_time_series_spec.js b/spec/javascripts/monitoring/utils/multiple_time_series_spec.js new file mode 100644 index 00000000000..3daf6bf82df --- /dev/null +++ b/spec/javascripts/monitoring/utils/multiple_time_series_spec.js @@ -0,0 +1,21 @@ +import createTimeSeries from '~/monitoring/utils/multiple_time_series'; +import { convertDatesMultipleSeries, singleRowMetricsMultipleSeries } from '../mock_data'; + +const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); +const timeSeries = createTimeSeries(convertedMetrics[0].queries[0].result, 428, 272, 120); + +describe('Multiple time series', () => { + it('createTimeSeries returned array contains an object for each element', () => { + expect(typeof timeSeries[0].linePath).toEqual('string'); + expect(typeof timeSeries[0].areaPath).toEqual('string'); + expect(typeof timeSeries[0].timeSeriesScaleX).toEqual('function'); + expect(typeof timeSeries[0].areaColor).toEqual('string'); + expect(typeof timeSeries[0].lineColor).toEqual('string'); + expect(timeSeries[0].values instanceof Array).toEqual(true); + }); + + it('createTimeSeries returns an array', () => { + expect(timeSeries instanceof Array).toEqual(true); + expect(timeSeries.length).toEqual(2); + }); +}); diff --git a/spec/javascripts/notes/components/issue_comment_form_spec.js b/spec/javascripts/notes/components/issue_comment_form_spec.js new file mode 100644 index 00000000000..cca5ec887a3 --- /dev/null +++ b/spec/javascripts/notes/components/issue_comment_form_spec.js @@ -0,0 +1,134 @@ +import Vue from 'vue'; +import store from '~/notes/stores'; +import issueCommentForm from '~/notes/components/issue_comment_form.vue'; +import { loggedOutIssueData, notesDataMock, userDataMock, issueDataMock } from '../mock_data'; +import { keyboardDownEvent } from '../../issue_show/helpers'; + +describe('issue_comment_form component', () => { + let vm; + const Component = Vue.extend(issueCommentForm); + let mountComponent; + + beforeEach(() => { + mountComponent = () => new Component({ + store, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('user is logged in', () => { + beforeEach(() => { + store.dispatch('setUserData', userDataMock); + store.dispatch('setIssueData', issueDataMock); + store.dispatch('setNotesData', notesDataMock); + + vm = mountComponent(); + }); + + it('should render user avatar with link', () => { + expect(vm.$el.querySelector('.timeline-icon .user-avatar-link').getAttribute('href')).toEqual(userDataMock.path); + }); + + describe('textarea', () => { + it('should render textarea with placeholder', () => { + expect( + vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'), + ).toEqual('Write a comment or drag your files here...'); + }); + + it('should support quick actions', () => { + expect( + vm.$el.querySelector('.js-main-target-form textarea').getAttribute('data-supports-quick-actions'), + ).toEqual('true'); + }); + + it('should link to markdown docs', () => { + const { markdownDocsPath } = notesDataMock; + expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual('Markdown'); + }); + + it('should link to quick actions docs', () => { + const { quickActionsDocsPath } = notesDataMock; + expect(vm.$el.querySelector(`a[href="${quickActionsDocsPath}"]`).textContent.trim()).toEqual('quick actions'); + }); + + describe('edit mode', () => { + it('should enter edit mode when arrow up is pressed', () => { + spyOn(vm, 'editCurrentUserLastNote').and.callThrough(); + vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo'; + vm.$el.querySelector('.js-main-target-form textarea').dispatchEvent(keyboardDownEvent(38, true)); + + expect(vm.editCurrentUserLastNote).toHaveBeenCalled(); + }); + }); + + describe('event enter', () => { + it('should save note when cmd/ctrl+enter is pressed', () => { + spyOn(vm, 'handleSave').and.callThrough(); + vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo'; + vm.$el.querySelector('.js-main-target-form textarea').dispatchEvent(keyboardDownEvent(13, true)); + + expect(vm.handleSave).toHaveBeenCalled(); + }); + }); + }); + + describe('actions', () => { + it('should be possible to close the issue', () => { + expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual('Close issue'); + }); + + it('should render comment button as disabled', () => { + expect(vm.$el.querySelector('.js-comment-submit-button').getAttribute('disabled')).toEqual('disabled'); + }); + + it('should enable comment button if it has note', (done) => { + vm.note = 'Foo'; + Vue.nextTick(() => { + expect(vm.$el.querySelector('.js-comment-submit-button').getAttribute('disabled')).toEqual(null); + done(); + }); + }); + + it('should update buttons texts when it has note', (done) => { + vm.note = 'Foo'; + Vue.nextTick(() => { + expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual('Comment & close issue'); + expect(vm.$el.querySelector('.js-note-discard')).toBeDefined(); + done(); + }); + }); + }); + + describe('issue is confidential', () => { + it('shows information warning', (done) => { + store.dispatch('setIssueData', Object.assign(issueDataMock, { confidential: true })); + Vue.nextTick(() => { + expect(vm.$el.querySelector('.confidential-issue-warning')).toBeDefined(); + done(); + }); + }); + }); + }); + + describe('user is not logged in', () => { + beforeEach(() => { + store.dispatch('setUserData', null); + store.dispatch('setIssueData', loggedOutIssueData); + store.dispatch('setNotesData', notesDataMock); + + vm = mountComponent(); + }); + + it('should render signed out widget', () => { + expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual('Please register or sign in to reply'); + }); + + it('should not render submission form', () => { + expect(vm.$el.querySelector('textarea')).toEqual(null); + }); + }); +}); diff --git a/spec/javascripts/notes/components/issue_discussion_spec.js b/spec/javascripts/notes/components/issue_discussion_spec.js new file mode 100644 index 00000000000..05c6b57f93e --- /dev/null +++ b/spec/javascripts/notes/components/issue_discussion_spec.js @@ -0,0 +1,50 @@ +import Vue from 'vue'; +import store from '~/notes/stores'; +import issueDiscussion from '~/notes/components/issue_discussion.vue'; +import { issueDataMock, discussionMock, notesDataMock } from '../mock_data'; + +describe('issue_discussion component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(issueDiscussion); + + store.dispatch('setIssueData', issueDataMock); + store.dispatch('setNotesData', notesDataMock); + + vm = new Component({ + store, + propsData: { + note: discussionMock, + }, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render user avatar', () => { + expect(vm.$el.querySelector('.user-avatar-link')).toBeDefined(); + }); + + it('should render discussion header', () => { + expect(vm.$el.querySelector('.discussion-header')).toBeDefined(); + expect(vm.$el.querySelectorAll('.notes li').length).toEqual(discussionMock.notes.length); + }); + + describe('actions', () => { + it('should render reply button', () => { + expect(vm.$el.querySelector('.js-vue-discussion-reply').textContent.trim()).toEqual('Reply...'); + }); + + it('should toggle reply form', (done) => { + vm.$el.querySelector('.js-vue-discussion-reply').click(); + Vue.nextTick(() => { + expect(vm.$refs.noteForm).toBeDefined(); + expect(vm.isReplying).toEqual(true); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/notes/components/issue_note_actions_spec.js b/spec/javascripts/notes/components/issue_note_actions_spec.js new file mode 100644 index 00000000000..7bcc061f167 --- /dev/null +++ b/spec/javascripts/notes/components/issue_note_actions_spec.js @@ -0,0 +1,91 @@ +import Vue from 'vue'; +import store from '~/notes/stores'; +import issueActions from '~/notes/components/issue_note_actions.vue'; +import { userDataMock } from '../mock_data'; + +describe('issse_note_actions component', () => { + let vm; + let Component; + + beforeEach(() => { + Component = Vue.extend(issueActions); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('user is logged in', () => { + let props; + + beforeEach(() => { + props = { + accessLevel: 'Master', + authorId: 26, + canDelete: true, + canEdit: true, + canReportAsAbuse: true, + noteId: 539, + reportAbusePath: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26', + }; + + store.dispatch('setUserData', userDataMock); + + vm = new Component({ + store, + propsData: props, + }).$mount(); + }); + + it('should render access level badge', () => { + expect(vm.$el.querySelector('.note-role').textContent.trim()).toEqual(props.accessLevel); + }); + + it('should render emoji link', () => { + expect(vm.$el.querySelector('.js-add-award')).toBeDefined(); + }); + + describe('actions dropdown', () => { + it('should be possible to edit the comment', () => { + expect(vm.$el.querySelector('.js-note-edit')).toBeDefined(); + }); + + it('should be possible to report as abuse', () => { + expect(vm.$el.querySelector(`a[href="${props.reportAbusePath}"]`)).toBeDefined(); + }); + + it('should be possible to delete comment', () => { + expect(vm.$el.querySelector('.js-note-delete')).toBeDefined(); + }); + }); + }); + + describe('user is not logged in', () => { + let props; + + beforeEach(() => { + store.dispatch('setUserData', {}); + props = { + accessLevel: 'Master', + authorId: 26, + canDelete: false, + canEdit: false, + canReportAsAbuse: false, + noteId: 539, + reportAbusePath: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26', + }; + vm = new Component({ + store, + propsData: props, + }).$mount(); + }); + + it('should not render emoji link', () => { + expect(vm.$el.querySelector('.js-add-award')).toEqual(null); + }); + + it('should not render actions dropdown', () => { + expect(vm.$el.querySelector('.more-actions')).toEqual(null); + }); + }); +}); diff --git a/spec/javascripts/notes/components/issue_note_app_spec.js b/spec/javascripts/notes/components/issue_note_app_spec.js new file mode 100644 index 00000000000..22e91c4c40f --- /dev/null +++ b/spec/javascripts/notes/components/issue_note_app_spec.js @@ -0,0 +1,255 @@ +import Vue from 'vue'; +import issueNotesApp from '~/notes/components/issue_notes_app.vue'; +import service from '~/notes/services/issue_notes_service'; +import * as mockData from '../mock_data'; + +describe('issue_note_app', () => { + let mountComponent; + let vm; + + const individualNoteInterceptor = (request, next) => { + next(request.respondWith(JSON.stringify(mockData.individualNoteServerResponse), { + status: 200, + })); + }; + + const discussionNoteInterceptor = (request, next) => { + next(request.respondWith(JSON.stringify(mockData.discussionNoteServerResponse), { + status: 200, + })); + }; + + beforeEach(() => { + const IssueNotesApp = Vue.extend(issueNotesApp); + + mountComponent = (data) => { + const props = data || { + issueData: mockData.issueDataMock, + notesData: mockData.notesDataMock, + userData: mockData.userDataMock, + }; + + return new IssueNotesApp({ + propsData: props, + }).$mount(); + }; + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('set data', () => { + const responseInterceptor = (request, next) => { + next(request.respondWith(JSON.stringify([]), { + status: 200, + })); + }; + + beforeEach(() => { + Vue.http.interceptors.push(responseInterceptor); + vm = mountComponent(); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, responseInterceptor); + }); + + it('should set notes data', () => { + expect(vm.$store.state.notesData).toEqual(mockData.notesDataMock); + }); + + it('should set issue data', () => { + expect(vm.$store.state.issueData).toEqual(mockData.issueDataMock); + }); + + it('should set user data', () => { + expect(vm.$store.state.userData).toEqual(mockData.userDataMock); + }); + + it('should fetch notes', () => { + expect(vm.$store.state.notes).toEqual([]); + }); + }); + + describe('render', () => { + beforeEach(() => { + Vue.http.interceptors.push(individualNoteInterceptor); + vm = mountComponent(); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, individualNoteInterceptor); + }); + + it('should render list of notes', (done) => { + const note = mockData.individualNoteServerResponse[0].notes[0]; + + setTimeout(() => { + expect( + vm.$el.querySelector('.main-notes-list .note-header-author-name').textContent.trim(), + ).toEqual(note.author.name); + + expect(vm.$el.querySelector('.main-notes-list .note-text').innerHTML).toEqual(note.note_html); + done(); + }, 0); + }); + + it('should render form', () => { + expect(vm.$el.querySelector('.js-main-target-form').tagName).toEqual('FORM'); + expect( + vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'), + ).toEqual('Write a comment or drag your files here...'); + }); + + it('should render form comment button as disabled', () => { + expect( + vm.$el.querySelector('.js-note-new-discussion').getAttribute('disabled'), + ).toEqual('disabled'); + }); + }); + + describe('while fetching data', () => { + beforeEach(() => { + vm = mountComponent(); + }); + + it('should render loading icon', () => { + expect(vm.$el.querySelector('.js-loading')).toBeDefined(); + }); + + it('should render form', () => { + expect(vm.$el.querySelector('.js-main-target-form').tagName).toEqual('FORM'); + expect( + vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'), + ).toEqual('Write a comment or drag your files here...'); + }); + }); + + describe('update note', () => { + describe('individual note', () => { + beforeEach(() => { + Vue.http.interceptors.push(individualNoteInterceptor); + spyOn(service, 'updateNote').and.callFake(() => Promise.resolve()); + vm = mountComponent(); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, individualNoteInterceptor); + }); + + it('renders edit form', (done) => { + setTimeout(() => { + vm.$el.querySelector('.js-note-edit').click(); + Vue.nextTick(() => { + expect(vm.$el.querySelector('.js-vue-issue-note-form')).toBeDefined(); + done(); + }); + }, 0); + }); + + it('calls the service to update the note', (done) => { + setTimeout(() => { + vm.$el.querySelector('.js-note-edit').click(); + Vue.nextTick(() => { + vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note'; + vm.$el.querySelector('.js-vue-issue-save').click(); + + expect(service.updateNote).toHaveBeenCalled(); + done(); + }); + }, 0); + }); + }); + + describe('dicussion note', () => { + beforeEach(() => { + Vue.http.interceptors.push(discussionNoteInterceptor); + spyOn(service, 'updateNote').and.callFake(() => Promise.resolve()); + vm = mountComponent(); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, discussionNoteInterceptor); + }); + + it('renders edit form', (done) => { + setTimeout(() => { + vm.$el.querySelector('.js-note-edit').click(); + Vue.nextTick(() => { + expect(vm.$el.querySelector('.js-vue-issue-note-form')).toBeDefined(); + done(); + }); + }, 0); + }); + + it('updates the note and resets the edit form', (done) => { + setTimeout(() => { + vm.$el.querySelector('.js-note-edit').click(); + Vue.nextTick(() => { + vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note'; + vm.$el.querySelector('.js-vue-issue-save').click(); + + expect(service.updateNote).toHaveBeenCalled(); + done(); + }); + }, 0); + }); + }); + }); + + describe('new note form', () => { + beforeEach(() => { + vm = mountComponent(); + }); + + it('should render markdown docs url', () => { + const { markdownDocsPath } = mockData.notesDataMock; + expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual('Markdown'); + }); + + it('should render quick action docs url', () => { + const { quickActionsDocsPath } = mockData.notesDataMock; + expect(vm.$el.querySelector(`a[href="${quickActionsDocsPath}"]`).textContent.trim()).toEqual('quick actions'); + }); + }); + + describe('edit form', () => { + beforeEach(() => { + Vue.http.interceptors.push(individualNoteInterceptor); + vm = mountComponent(); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, individualNoteInterceptor); + }); + + it('should render markdown docs url', (done) => { + setTimeout(() => { + vm.$el.querySelector('.js-note-edit').click(); + const { markdownDocsPath } = mockData.notesDataMock; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector(`.edit-note a[href="${markdownDocsPath}"]`).textContent.trim(), + ).toEqual('Markdown is supported'); + done(); + }); + }, 0); + }); + + it('should not render quick actions docs url', (done) => { + setTimeout(() => { + vm.$el.querySelector('.js-note-edit').click(); + const { quickActionsDocsPath } = mockData.notesDataMock; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector(`.edit-note a[href="${quickActionsDocsPath}"]`), + ).toEqual(null); + done(); + }); + }, 0); + }); + }); +}); diff --git a/spec/javascripts/notes/components/issue_note_attachment_spec.js b/spec/javascripts/notes/components/issue_note_attachment_spec.js new file mode 100644 index 00000000000..8f33b874ad6 --- /dev/null +++ b/spec/javascripts/notes/components/issue_note_attachment_spec.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import issueNoteAttachment from '~/notes/components/issue_note_attachment.vue'; + +describe('issue note attachment', () => { + it('should render properly', () => { + const props = { + attachment: { + filename: 'dk.png', + image: true, + url: '/dk.png', + }, + }; + + const Component = Vue.extend(issueNoteAttachment); + const vm = new Component({ + propsData: props, + }).$mount(); + + expect(vm.$el.classList.contains('note-attachment')).toBeTruthy(); + expect(vm.$el.querySelector('img').src).toContain(props.attachment.url); + expect(vm.$el.querySelector('a').href).toContain(props.attachment.url); + }); +}); diff --git a/spec/javascripts/notes/components/issue_note_awards_list_spec.js b/spec/javascripts/notes/components/issue_note_awards_list_spec.js new file mode 100644 index 00000000000..3b6c34f1494 --- /dev/null +++ b/spec/javascripts/notes/components/issue_note_awards_list_spec.js @@ -0,0 +1,56 @@ +import Vue from 'vue'; +import store from '~/notes/stores'; +import awardsNote from '~/notes/components/issue_note_awards_list.vue'; +import { issueDataMock, notesDataMock } from '../mock_data'; + +describe('issue_note_awards_list component', () => { + let vm; + let awardsMock; + + beforeEach(() => { + const Component = Vue.extend(awardsNote); + + store.dispatch('setIssueData', issueDataMock); + store.dispatch('setNotesData', notesDataMock); + awardsMock = [ + { + name: 'flag_tz', + user: { id: 1, name: 'Administrator', username: 'root' }, + }, + { + name: 'cartwheel_tone3', + user: { id: 12, name: 'Bobbie Stehr', username: 'erin' }, + }, + ]; + + vm = new Component({ + store, + propsData: { + awards: awardsMock, + noteAuthorId: 2, + noteId: 545, + toggleAwardPath: '/gitlab-org/gitlab-ce/notes/545/toggle_award_emoji', + }, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render awarded emojis', () => { + expect(vm.$el.querySelector('.js-awards-block button [data-name="flag_tz"]')).toBeDefined(); + expect(vm.$el.querySelector('.js-awards-block button [data-name="cartwheel_tone3"]')).toBeDefined(); + }); + + it('should be possible to remove awareded emoji', () => { + spyOn(vm, 'handleAward').and.callThrough(); + vm.$el.querySelector('.js-awards-block button').click(); + + expect(vm.handleAward).toHaveBeenCalledWith('flag_tz'); + }); + + it('should be possible to add new emoji', () => { + expect(vm.$el.querySelector('.js-add-award')).toBeDefined(); + }); +}); diff --git a/spec/javascripts/notes/components/issue_note_body_spec.js b/spec/javascripts/notes/components/issue_note_body_spec.js new file mode 100644 index 00000000000..81f07ed47cc --- /dev/null +++ b/spec/javascripts/notes/components/issue_note_body_spec.js @@ -0,0 +1,46 @@ + +import Vue from 'vue'; +import store from '~/notes/stores'; +import noteBody from '~/notes/components/issue_note_body.vue'; +import { issueDataMock, notesDataMock, note } from '../mock_data'; + +describe('issue_note_body component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(noteBody); + + store.dispatch('setIssueData', issueDataMock); + store.dispatch('setNotesData', notesDataMock); + + vm = new Component({ + store, + propsData: { + note, + canEdit: true, + }, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render the note', () => { + expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html); + }); + + it('should be render form if user is editing', (done) => { + vm.isEditing = true; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('textarea.js-task-list-field')).toBeDefined(); + done(); + }); + }); + + it('should render awards list', () => { + expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).toBeDefined(); + expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).toBeDefined(); + }); +}); diff --git a/spec/javascripts/notes/components/issue_note_edited_text_spec.js b/spec/javascripts/notes/components/issue_note_edited_text_spec.js new file mode 100644 index 00000000000..6603241eb64 --- /dev/null +++ b/spec/javascripts/notes/components/issue_note_edited_text_spec.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import issueNoteEditedText from '~/notes/components/issue_note_edited_text.vue'; + +describe('issue_note_edited_text', () => { + let vm; + let props; + + beforeEach(() => { + const Component = Vue.extend(issueNoteEditedText); + props = { + actionText: 'Edited', + className: 'foo-bar', + editedAt: '2017-08-04T09:52:31.062Z', + editedBy: { + avatar_url: 'path', + id: 1, + name: 'Root', + path: '/root', + state: 'active', + username: 'root', + }, + }; + + vm = new Component({ + propsData: props, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render block with provided className', () => { + expect(vm.$el.className).toEqual(props.className); + }); + + it('should render provided actionText', () => { + expect(vm.$el.textContent).toContain(props.actionText); + }); + + it('should render provided user information', () => { + const authorLink = vm.$el.querySelector('.js-vue-author'); + + expect(authorLink.getAttribute('href')).toEqual(props.editedBy.path); + expect(authorLink.textContent.trim()).toEqual(props.editedBy.name); + }); +}); diff --git a/spec/javascripts/notes/components/issue_note_form_spec.js b/spec/javascripts/notes/components/issue_note_form_spec.js new file mode 100644 index 00000000000..a90dbcb72b5 --- /dev/null +++ b/spec/javascripts/notes/components/issue_note_form_spec.js @@ -0,0 +1,112 @@ +import Vue from 'vue'; +import store from '~/notes/stores'; +import issueNoteForm from '~/notes/components/issue_note_form.vue'; +import { issueDataMock, notesDataMock } from '../mock_data'; +import { keyboardDownEvent } from '../../issue_show/helpers'; + +describe('issue_note_form component', () => { + let vm; + let props; + + beforeEach(() => { + const Component = Vue.extend(issueNoteForm); + + store.dispatch('setIssueData', issueDataMock); + store.dispatch('setNotesData', notesDataMock); + + props = { + isEditing: false, + noteBody: 'Magni suscipit eius consectetur enim et ex et commodi.', + noteId: 545, + }; + + vm = new Component({ + store, + propsData: props, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('conflicts editing', () => { + it('should show conflict message if note changes outside the component', (done) => { + vm.isEditing = true; + vm.noteBody = 'Foo'; + const message = 'This comment has changed since you started editing, please review the updated comment to ensure information is not lost.'; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.js-conflict-edit-warning').textContent.replace(/\s+/g, ' ').trim(), + ).toEqual(message); + done(); + }); + }); + }); + + describe('form', () => { + it('should render text area with placeholder', () => { + expect( + vm.$el.querySelector('textarea').getAttribute('placeholder'), + ).toEqual('Write a comment or drag your files here...'); + }); + + it('should link to markdown docs', () => { + const { markdownDocsPath } = notesDataMock; + expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual('Markdown'); + }); + + describe('keyboard events', () => { + describe('up', () => { + it('should ender edit mode', () => { + spyOn(vm, 'editMyLastNote').and.callThrough(); + vm.$el.querySelector('textarea').value = 'Foo'; + vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(38, true)); + + expect(vm.editMyLastNote).toHaveBeenCalled(); + }); + }); + + describe('enter', () => { + it('should submit note', () => { + spyOn(vm, 'handleUpdate').and.callThrough(); + vm.$el.querySelector('textarea').value = 'Foo'; + vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(13, true)); + + expect(vm.handleUpdate).toHaveBeenCalled(); + }); + }); + }); + + describe('actions', () => { + it('should be possible to cancel', (done) => { + spyOn(vm, 'cancelHandler').and.callThrough(); + vm.isEditing = true; + + Vue.nextTick(() => { + vm.$el.querySelector('.note-edit-cancel').click(); + + Vue.nextTick(() => { + expect(vm.cancelHandler).toHaveBeenCalled(); + done(); + }); + }); + }); + + it('should be possible to update the note', (done) => { + vm.isEditing = true; + + Vue.nextTick(() => { + vm.$el.querySelector('textarea').value = 'Foo'; + vm.$el.querySelector('.js-vue-issue-save').click(); + + Vue.nextTick(() => { + expect(vm.isSubmitting).toEqual(true); + done(); + }); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/notes/components/issue_note_header_spec.js b/spec/javascripts/notes/components/issue_note_header_spec.js new file mode 100644 index 00000000000..83ea18508ae --- /dev/null +++ b/spec/javascripts/notes/components/issue_note_header_spec.js @@ -0,0 +1,94 @@ +import Vue from 'vue'; +import issueNoteHeader from '~/notes/components/issue_note_header.vue'; +import store from '~/notes/stores'; + +describe('issue_note_header component', () => { + let vm; + let Component; + + beforeEach(() => { + Component = Vue.extend(issueNoteHeader); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('individual note', () => { + beforeEach(() => { + vm = new Component({ + store, + propsData: { + actionText: 'commented', + actionTextHtml: '', + author: { + avatar_url: null, + id: 1, + name: 'Root', + path: '/root', + state: 'active', + username: 'root', + }, + createdAt: '2017-08-02T10:51:58.559Z', + includeToggle: false, + noteId: 1394, + }, + }).$mount(); + }); + + it('should render user information', () => { + expect( + vm.$el.querySelector('.note-header-author-name').textContent.trim(), + ).toEqual('Root'); + expect( + vm.$el.querySelector('.note-header-info a').getAttribute('href'), + ).toEqual('/root'); + }); + + it('should render timestamp link', () => { + expect(vm.$el.querySelector('a[href="#note_1394"]')).toBeDefined(); + }); + }); + + describe('discussion', () => { + beforeEach(() => { + vm = new Component({ + store, + propsData: { + actionText: 'started a discussion', + actionTextHtml: '', + author: { + avatar_url: null, + id: 1, + name: 'Root', + path: '/root', + state: 'active', + username: 'root', + }, + createdAt: '2017-08-02T10:51:58.559Z', + includeToggle: true, + noteId: 1395, + }, + }).$mount(); + }); + + it('should render toggle button', () => { + expect(vm.$el.querySelector('.js-vue-toggle-button')).toBeDefined(); + }); + + it('should toggle the disucssion icon', (done) => { + expect( + vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-up'), + ).toEqual(true); + + vm.$el.querySelector('.js-vue-toggle-button').click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-down'), + ).toEqual(true); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/notes/components/issue_note_signed_out_widget_spec.js b/spec/javascripts/notes/components/issue_note_signed_out_widget_spec.js new file mode 100644 index 00000000000..f20d9ce9268 --- /dev/null +++ b/spec/javascripts/notes/components/issue_note_signed_out_widget_spec.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; +import issueNoteSignedOut from '~/notes/components/issue_note_signed_out_widget.vue'; +import store from '~/notes/stores'; +import { notesDataMock } from '../mock_data'; + +describe('issue_note_signed_out_widget component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(issueNoteSignedOut); + store.dispatch('setNotesData', notesDataMock); + + vm = new Component({ + store, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render sign in link provided in the store', () => { + expect( + vm.$el.querySelector(`a[href="${notesDataMock.newSessionPath}"]`).textContent, + ).toEqual('sign in'); + }); + + it('should render register link provided in the store', () => { + expect( + vm.$el.querySelector(`a[href="${notesDataMock.registerPath}"]`).textContent, + ).toEqual('register'); + }); + + it('should render information text', () => { + expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual('Please register or sign in to reply'); + }); +}); diff --git a/spec/javascripts/notes/components/issue_note_spec.js b/spec/javascripts/notes/components/issue_note_spec.js new file mode 100644 index 00000000000..7ef85d5b4f0 --- /dev/null +++ b/spec/javascripts/notes/components/issue_note_spec.js @@ -0,0 +1,44 @@ + +import Vue from 'vue'; +import store from '~/notes/stores'; +import issueNote from '~/notes/components/issue_note.vue'; +import { issueDataMock, notesDataMock, note } from '../mock_data'; + +describe('issue_note', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(issueNote); + + store.dispatch('setIssueData', issueDataMock); + store.dispatch('setNotesData', notesDataMock); + + vm = new Component({ + store, + propsData: { + note, + }, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render user information', () => { + expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual(note.author.avatar_url); + }); + + it('should render note header content', () => { + expect(vm.$el.querySelector('.note-header .note-header-author-name').textContent.trim()).toEqual(note.author.name); + expect(vm.$el.querySelector('.note-header .note-headline-meta').textContent.trim()).toContain('commented'); + }); + + it('should render note actions', () => { + expect(vm.$el.querySelector('.note-actions')).toBeDefined(); + }); + + it('should render issue body', () => { + expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html); + }); +}); diff --git a/spec/javascripts/notes/components/issue_placeholder_note_spec.js b/spec/javascripts/notes/components/issue_placeholder_note_spec.js new file mode 100644 index 00000000000..6e5275087f3 --- /dev/null +++ b/spec/javascripts/notes/components/issue_placeholder_note_spec.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import issuePlaceholderNote from '~/notes/components/issue_placeholder_note.vue'; +import store from '~/notes/stores'; +import { userDataMock } from '../mock_data'; + +describe('issue placeholder system note component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(issuePlaceholderNote); + store.dispatch('setUserData', userDataMock); + vm = new Component({ + store, + propsData: { note: { body: 'Foo' } }, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('user information', () => { + it('should render user avatar with link', () => { + expect(vm.$el.querySelector('.user-avatar-link').getAttribute('href')).toEqual(userDataMock.path); + expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual(userDataMock.avatar_url); + }); + }); + + describe('note content', () => { + it('should render note header information', () => { + expect(vm.$el.querySelector('.note-header-info a').getAttribute('href')).toEqual(userDataMock.path); + expect(vm.$el.querySelector('.note-header-info .note-headline-light').textContent.trim()).toEqual(`@${userDataMock.username}`); + }); + + it('should render note body', () => { + expect(vm.$el.querySelector('.note-text p').textContent.trim()).toEqual('Foo'); + }); + }); +}); diff --git a/spec/javascripts/notes/components/issue_placeholder_system_note_spec.js b/spec/javascripts/notes/components/issue_placeholder_system_note_spec.js new file mode 100644 index 00000000000..d508a49f710 --- /dev/null +++ b/spec/javascripts/notes/components/issue_placeholder_system_note_spec.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import placeholderSystemNote from '~/notes/components/issue_placeholder_system_note.vue'; + +describe('issue placeholder system note component', () => { + let mountComponent; + beforeEach(() => { + const PlaceholderSystemNote = Vue.extend(placeholderSystemNote); + + mountComponent = props => new PlaceholderSystemNote({ + propsData: { + note: { + body: props, + }, + }, + }).$mount(); + }); + + it('should render system note placeholder with plain text', () => { + const vm = mountComponent('This is a placeholder'); + + expect(vm.$el.tagName).toEqual('LI'); + expect(vm.$el.querySelector('.timeline-content em').textContent.trim()).toEqual('This is a placeholder'); + }); +}); diff --git a/spec/javascripts/notes/components/issue_system_note_spec.js b/spec/javascripts/notes/components/issue_system_note_spec.js new file mode 100644 index 00000000000..c317ce32716 --- /dev/null +++ b/spec/javascripts/notes/components/issue_system_note_spec.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import issueSystemNote from '~/notes/components/issue_system_note.vue'; +import store from '~/notes/stores'; + +describe('issue system note', () => { + let vm; + let props; + + beforeEach(() => { + props = { + note: { + id: 1424, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: 'path', + path: '/root', + }, + note_html: '<p dir="auto">closed</p>', + system_note_icon_name: 'icon_status_closed', + created_at: '2017-08-02T10:51:58.559Z', + }, + }; + + store.dispatch('setTargetNoteHash', `note_${props.note.id}`); + + const Component = Vue.extend(issueSystemNote); + vm = new Component({ + store, + propsData: props, + }).$mount(); + }); + + it('should render a list item with correct id', () => { + expect(vm.$el.getAttribute('id')).toEqual(`note_${props.note.id}`); + }); + + it('should render target class is note is target note', () => { + expect(vm.$el.classList).toContain('target'); + }); + + it('should render svg icon', () => { + expect(vm.$el.querySelector('.timeline-icon svg')).toBeDefined(); + }); + + it('should render note header component', () => { + expect( + vm.$el.querySelector('.system-note-message').innerHTML, + ).toEqual(props.note.note_html); + }); +}); diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js new file mode 100644 index 00000000000..89ba3a002b7 --- /dev/null +++ b/spec/javascripts/notes/mock_data.js @@ -0,0 +1,449 @@ +/* eslint-disable */ +export const notesDataMock = { + discussionsPath: '/gitlab-org/gitlab-ce/issues/26/discussions.json', + lastFetchedAt: '1501862675', + markdownDocsPath: '/help/user/markdown', + newSessionPath: '/users/sign_in?redirect_to_referer=yes', + notesPath: '/gitlab-org/gitlab-ce/noteable/issue/98/notes', + quickActionsDocsPath: '/help/user/project/quick_actions', + registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane', +}; + +export const userDataMock = { + avatar_url: 'mock_path', + id: 1, + name: 'Root', + path: '/root', + state: 'active', + username: 'root', +}; + +export const issueDataMock = { + assignees: [], + author_id: 1, + branch_name: null, + confidential: false, + create_note_path: '/gitlab-org/gitlab-ce/notes?target_id=98&target_type=issue', + created_at: '2017-02-07T10:11:18.395Z', + current_user: { + can_create_note: true, + can_update: true, + }, + deleted_at: null, + description: '', + due_date: null, + human_time_estimate: null, + human_total_time_spent: null, + id: 98, + iid: 26, + labels: [], + lock_version: null, + milestone: null, + milestone_id: null, + moved_to_id: null, + preview_note_path: '/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue', + project_id: 2, + state: 'opened', + time_estimate: 0, + title: '14', + total_time_spent: 0, + updated_at: '2017-08-04T09:53:01.226Z', + updated_by_id: 1, + web_url: '/gitlab-org/gitlab-ce/issues/26', +}; + +export const lastFetchedAt = '1501862675'; + +export const individualNote = { + expanded: true, + id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + individual_note: true, + notes: [{ + id: 1390, + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: 'test', + path: '/root', + }, + created_at: '2017-08-01T17: 09: 33.762Z', + updated_at: '2017-08-01T17: 09: 33.762Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: null, + human_access: 'Owner', + note: 'sdfdsaf', + note_html: '<p dir=\'auto\'>sdfdsaf</p>', + current_user: { can_edit: true }, + discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', + emoji_awardable: true, + award_emoji: [ + { name: 'baseball', user: { id: 1, name: 'Root', username: 'root' } }, + { name: 'art', user: { id: 1, name: 'Root', username: 'root' } }, + ], + toggle_award_path: '/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji', + report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390&user_id=1', + path: '/gitlab-org/gitlab-ce/notes/1390', + }], + reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd', +}; + +export const note = { + "id": 546, + "attachment": { + "url": null, + "filename": null, + "image": false + }, + "author": { + "id": 1, + "name": "Administrator", + "username": "root", + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "path": "/root" + }, + "created_at": "2017-08-10T15:24:03.087Z", + "updated_at": "2017-08-10T15:24:03.087Z", + "system": false, + "noteable_id": 67, + "noteable_type": "Issue", + "noteable_iid": 7, + "type": null, + "human_access": "Owner", + "note": "Vel id placeat reprehenderit sit numquam.", + "note_html": "<p dir=\"auto\">Vel id placeat reprehenderit sit numquam.</p>", + "current_user": { + "can_edit": true + }, + "discussion_id": "d3842a451b7f3d9a5dfce329515127b2d29a4cd0", + "emoji_awardable": true, + "award_emoji": [{ + "name": "baseball", + "user": { + "id": 1, + "name": "Administrator", + "username": "root" + } + }, { + "name": "bath_tone3", + "user": { + "id": 1, + "name": "Administrator", + "username": "root" + } + }], + "toggle_award_path": "/gitlab-org/gitlab-ce/notes/546/toggle_award_emoji", + "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1", + "path": "/gitlab-org/gitlab-ce/notes/546" + } + +export const discussionMock = { + id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + reply_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + expanded: true, + notes: [{ + id: 1395, + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-02T10:51:58.559Z', + updated_at: '2017-08-02T10:51:58.559Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: 'DiscussionNote', + human_access: 'Owner', + note: 'THIS IS A DICUSSSION!', + note_html: '<p dir=\'auto\'>THIS IS A DICUSSSION!</p>', + current_user: { + can_edit: true, + }, + discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-ce/notes/1395/toggle_award_emoji', + report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1395&user_id=1', + path: '/gitlab-org/gitlab-ce/notes/1395', + }, { + id: 1396, + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-02T10:56:50.980Z', + updated_at: '2017-08-03T14:19:35.691Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: 'DiscussionNote', + human_access: 'Owner', + note: 'sadfasdsdgdsf', + note_html: '<p dir=\'auto\'>sadfasdsdgdsf</p>', + last_edited_at: '2017-08-03T14:19:35.691Z', + last_edited_by: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + current_user: { + can_edit: true, + }, + discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-ce/notes/1396/toggle_award_emoji', + report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1396&user_id=1', + path: '/gitlab-org/gitlab-ce/notes/1396', + }, { + id: 1437, + attachment: { + url: null, + filename: null, + image: false, + }, + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + created_at: '2017-08-03T18:11:18.780Z', + updated_at: '2017-08-04T09:52:31.062Z', + system: false, + noteable_id: 98, + noteable_type: 'Issue', + type: 'DiscussionNote', + human_access: 'Owner', + note: 'adsfasf Should disappear', + note_html: '<p dir=\'auto\'>adsfasf Should disappear</p>', + last_edited_at: '2017-08-04T09:52:31.062Z', + last_edited_by: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatar_url: null, + path: '/root', + }, + current_user: { + can_edit: true, + }, + discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1', + emoji_awardable: true, + award_emoji: [], + toggle_award_path: '/gitlab-org/gitlab-ce/notes/1437/toggle_award_emoji', + report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1437&user_id=1', + path: '/gitlab-org/gitlab-ce/notes/1437', + }], + individual_note: false, +}; + +export const loggedOutIssueData = { + "id": 98, + "iid": 26, + "author_id": 1, + "description": "", + "lock_version": 1, + "milestone_id": null, + "state": "opened", + "title": "asdsa", + "updated_by_id": 1, + "created_at": "2017-02-07T10:11:18.395Z", + "updated_at": "2017-08-08T10:22:51.564Z", + "deleted_at": null, + "time_estimate": 0, + "total_time_spent": 0, + "human_time_estimate": null, + "human_total_time_spent": null, + "milestone": null, + "labels": [], + "branch_name": null, + "confidential": false, + "assignees": [{ + "id": 1, + "name": "Root", + "username": "root", + "state": "active", + "avatar_url": null, + "web_url": "http://localhost:3000/root" + }], + "due_date": null, + "moved_to_id": null, + "project_id": 2, + "web_url": "/gitlab-org/gitlab-ce/issues/26", + "current_user": { + "can_create_note": false, + "can_update": false + }, + "create_note_path": "/gitlab-org/gitlab-ce/notes?target_id=98&target_type=issue", + "preview_note_path": "/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue" +} + +export const individualNoteServerResponse = [{ + "id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd", + "reply_id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd", + "expanded": true, + "notes": [{ + "id": 1390, + "attachment": { + "url": null, + "filename": null, + "image": false + }, + "author": { + "id": 1, + "name": "Root", + "username": "root", + "state": "active", + "avatar_url": null, + "path": "/root" + }, + "created_at": "2017-08-01T17:09:33.762Z", + "updated_at": "2017-08-01T17:09:33.762Z", + "system": false, + "noteable_id": 98, + "noteable_type": "Issue", + "type": null, + "human_access": "Owner", + "note": "sdfdsaf", + "note_html": "\u003cp dir=\"auto\"\u003esdfdsaf\u003c/p\u003e", + "current_user": { + "can_edit": true + }, + "discussion_id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd", + "emoji_awardable": true, + "award_emoji": [{ + "name": "baseball", + "user": { + "id": 1, + "name": "Root", + "username": "root" + } + }, { + "name": "art", + "user": { + "id": 1, + "name": "Root", + "username": "root" + } + }], + "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji", + "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390\u0026user_id=1", + "path": "/gitlab-org/gitlab-ce/notes/1390" + }], + "individual_note": true + }, { + "id": "70d5c92a4039a36c70100c6691c18c27e4b0a790", + "reply_id": "70d5c92a4039a36c70100c6691c18c27e4b0a790", + "expanded": true, + "notes": [{ + "id": 1391, + "attachment": { + "url": null, + "filename": null, + "image": false + }, + "author": { + "id": 1, + "name": "Root", + "username": "root", + "state": "active", + "avatar_url": null, + "path": "/root" + }, + "created_at": "2017-08-02T10:51:38.685Z", + "updated_at": "2017-08-02T10:51:38.685Z", + "system": false, + "noteable_id": 98, + "noteable_type": "Issue", + "type": null, + "human_access": "Owner", + "note": "New note!", + "note_html": "\u003cp dir=\"auto\"\u003eNew note!\u003c/p\u003e", + "current_user": { + "can_edit": true + }, + "discussion_id": "70d5c92a4039a36c70100c6691c18c27e4b0a790", + "emoji_awardable": true, + "award_emoji": [], + "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1391/toggle_award_emoji", + "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1391\u0026user_id=1", + "path": "/gitlab-org/gitlab-ce/notes/1391" + }], + "individual_note": true +}]; + +export const discussionNoteServerResponse = [{ + "id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052", + "reply_id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052", + "expanded": true, + "notes": [{ + "id": 1471, + "attachment": { + "url": null, + "filename": null, + "image": false + }, + "author": { + "id": 1, + "name": "Root", + "username": "root", + "state": "active", + "avatar_url": null, + "path": "/root" + }, + "created_at": "2017-08-08T16:53:00.666Z", + "updated_at": "2017-08-08T16:53:00.666Z", + "system": false, + "noteable_id": 124, + "noteable_type": "Issue", + "noteable_iid": 29, + "type": "DiscussionNote", + "human_access": "Owner", + "note": "Adding a comment", + "note_html": "\u003cp dir=\"auto\"\u003eAdding a comment\u003c/p\u003e", + "current_user": { + "can_edit": true + }, + "discussion_id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052", + "emoji_awardable": true, + "award_emoji": [], + "toggle_award_path": "/gitlab-org/gitlab-ce/notes/1471/toggle_award_emoji", + "report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1", + "path": "/gitlab-org/gitlab-ce/notes/1471" + }], + "individual_note": false +}]; diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js new file mode 100644 index 00000000000..72d362acb2f --- /dev/null +++ b/spec/javascripts/notes/stores/actions_spec.js @@ -0,0 +1,62 @@ + +import * as actions from '~/notes/stores/actions'; +import testAction from './helpers'; +import { discussionMock, notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data'; + +describe('Actions Notes Store', () => { + describe('setNotesData', () => { + it('should set received notes data', (done) => { + testAction(actions.setNotesData, null, { notesData: {} }, [ + { type: 'SET_NOTES_DATA', payload: notesDataMock }, + ], done); + }); + }); + + describe('setIssueData', () => { + it('should set received issue data', (done) => { + testAction(actions.setIssueData, null, { issueData: {} }, [ + { type: 'SET_ISSUE_DATA', payload: issueDataMock }, + ], done); + }); + }); + + describe('setUserData', () => { + it('should set received user data', (done) => { + testAction(actions.setUserData, null, { userData: {} }, [ + { type: 'SET_USER_DATA', payload: userDataMock }, + ], done); + }); + }); + + describe('setLastFetchedAt', () => { + it('should set received timestamp', (done) => { + testAction(actions.setLastFetchedAt, null, { lastFetchedAt: {} }, [ + { type: 'SET_LAST_FETCHED_AT', payload: 'timestamp' }, + ], done); + }); + }); + + describe('setInitialNotes', () => { + it('should set initial notes', (done) => { + testAction(actions.setInitialNotes, null, { notes: [] }, [ + { type: 'SET_INITIAL_NOTES', payload: [individualNote] }, + ], done); + }); + }); + + describe('setTargetNoteHash', () => { + it('should set target note hash', (done) => { + testAction(actions.setTargetNoteHash, null, { notes: [] }, [ + { type: 'SET_TARGET_NOTE_HASH', payload: 'hash' }, + ], done); + }); + }); + + describe('toggleDiscussion', () => { + it('should toggle discussion', (done) => { + testAction(actions.toggleDiscussion, null, { notes: [discussionMock] }, [ + { type: 'TOGGLE_DISCUSSION', payload: { discussionId: discussionMock.id } }, + ], done); + }); + }); +}); diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/javascripts/notes/stores/getters_spec.js new file mode 100644 index 00000000000..48ee1bf9a52 --- /dev/null +++ b/spec/javascripts/notes/stores/getters_spec.js @@ -0,0 +1,58 @@ +import * as getters from '~/notes/stores/getters'; +import { notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data'; + +describe('Getters Notes Store', () => { + let state; + beforeEach(() => { + state = { + notes: [individualNote], + targetNoteHash: 'hash', + lastFetchedAt: 'timestamp', + + notesData: notesDataMock, + userData: userDataMock, + issueData: issueDataMock, + }; + }); + describe('notes', () => { + it('should return all notes in the store', () => { + expect(getters.notes(state)).toEqual([individualNote]); + }); + }); + + describe('targetNoteHash', () => { + it('should return `targetNoteHash`', () => { + expect(getters.targetNoteHash(state)).toEqual('hash'); + }); + }); + + describe('getNotesData', () => { + it('should return all data in `notesData`', () => { + expect(getters.getNotesData(state)).toEqual(notesDataMock); + }); + }); + + describe('getIssueData', () => { + it('should return all data in `issueData`', () => { + expect(getters.getIssueData(state)).toEqual(issueDataMock); + }); + }); + + describe('getUserData', () => { + it('should return all data in `userData`', () => { + expect(getters.getUserData(state)).toEqual(userDataMock); + }); + }); + + describe('notesById', () => { + it('should return the note for the given id', () => { + expect(getters.notesById(state)).toEqual({ 1390: individualNote.notes[0] }); + }); + }); + + describe('getCurrentUserLastNote', () => { + it('should return the last note of the current user', () => { + expect(getters.getCurrentUserLastNote(state)).toEqual(individualNote.notes[0]); + }); + }); +}); diff --git a/spec/javascripts/notes/stores/helpers.js b/spec/javascripts/notes/stores/helpers.js new file mode 100644 index 00000000000..2d386fe1da5 --- /dev/null +++ b/spec/javascripts/notes/stores/helpers.js @@ -0,0 +1,37 @@ +/* eslint-disable */ + +/** + * helper for testing action with expected mutations + * https://vuex.vuejs.org/en/testing.html + */ +export default (action, payload, state, expectedMutations, done) => { + let count = 0; + + // mock commit + const commit = (type, payload) => { + const mutation = expectedMutations[count]; + + try { + expect(mutation.type).to.equal(type); + if (payload) { + expect(mutation.payload).to.deep.equal(payload); + } + } catch (error) { + done(error); + } + + count++; + if (count >= expectedMutations.length) { + done(); + } + }; + + // call the action with mocked store and arguments + action({ commit, state }, payload); + + // check if no mutations should have been dispatched + if (expectedMutations.length === 0) { + expect(count).to.equal(0); + done(); + } +}; diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js new file mode 100644 index 00000000000..a38f29c1e39 --- /dev/null +++ b/spec/javascripts/notes/stores/mutation_spec.js @@ -0,0 +1,207 @@ +import mutations from '~/notes/stores/mutations'; +import { note, discussionMock, notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data'; + +describe('Mutation Notes Store', () => { + describe('ADD_NEW_NOTE', () => { + it('should add a new note to an array of notes', () => { + const state = { notes: [] }; + mutations.ADD_NEW_NOTE(state, note); + + expect(state).toEqual({ + notes: [{ + expanded: true, + id: note.discussion_id, + individual_note: true, + notes: [note], + reply_id: note.discussion_id, + }], + }); + }); + }); + + describe('ADD_NEW_REPLY_TO_DISCUSSION', () => { + it('should add a reply to a specific discussion', () => { + const state = { notes: [discussionMock] }; + const newReply = Object.assign({}, note, { discussion_id: discussionMock.id }); + mutations.ADD_NEW_REPLY_TO_DISCUSSION(state, newReply); + + expect(state.notes[0].notes.length).toEqual(4); + }); + }); + + describe('DELETE_NOTE', () => { + it('should delete a note ', () => { + const state = { notes: [discussionMock] }; + const toDelete = discussionMock.notes[0]; + const lengthBefore = discussionMock.notes.length; + + mutations.DELETE_NOTE(state, toDelete); + + expect(state.notes[0].notes.length).toEqual(lengthBefore - 1); + }); + }); + + describe('REMOVE_PLACEHOLDER_NOTES', () => { + it('should remove all placeholder notes in indivudal notes and discussion', () => { + const placeholderNote = Object.assign({}, individualNote, { isPlaceholderNote: true }); + const state = { notes: [placeholderNote] }; + mutations.REMOVE_PLACEHOLDER_NOTES(state); + + expect(state.notes).toEqual([]); + }); + }); + + describe('SET_NOTES_DATA', () => { + it('should set an object with notesData', () => { + const state = { + notesData: {}, + }; + + mutations.SET_NOTES_DATA(state, notesDataMock); + expect(state.notesData).toEqual(notesDataMock); + }); + }); + + describe('SET_ISSUE_DATA', () => { + it('should set the issue data', () => { + const state = { + issueData: {}, + }; + + mutations.SET_ISSUE_DATA(state, issueDataMock); + expect(state.issueData).toEqual(issueDataMock); + }); + }); + + describe('SET_USER_DATA', () => { + it('should set the user data', () => { + const state = { + userData: {}, + }; + + mutations.SET_USER_DATA(state, userDataMock); + expect(state.userData).toEqual(userDataMock); + }); + }); + + describe('SET_INITIAL_NOTES', () => { + it('should set the initial notes received', () => { + const state = { + notes: [], + }; + + mutations.SET_INITIAL_NOTES(state, [note]); + expect(state.notes).toEqual([note]); + }); + }); + + describe('SET_LAST_FETCHED_AT', () => { + it('should set timestamp', () => { + const state = { + lastFetchedAt: [], + }; + + mutations.SET_LAST_FETCHED_AT(state, 'timestamp'); + expect(state.lastFetchedAt).toEqual('timestamp'); + }); + }); + + describe('SET_TARGET_NOTE_HASH', () => { + it('should set the note hash', () => { + const state = { + targetNoteHash: [], + }; + + mutations.SET_TARGET_NOTE_HASH(state, 'hash'); + expect(state.targetNoteHash).toEqual('hash'); + }); + }); + + describe('SHOW_PLACEHOLDER_NOTE', () => { + it('should set a placeholder note', () => { + const state = { + notes: [], + }; + mutations.SHOW_PLACEHOLDER_NOTE(state, note); + expect(state.notes[0].isPlaceholderNote).toEqual(true); + }); + }); + + describe('TOGGLE_AWARD', () => { + it('should add award if user has not reacted yet', () => { + const state = { + notes: [note], + userData: userDataMock, + }; + + const data = { + note, + awardName: 'cartwheel', + }; + + mutations.TOGGLE_AWARD(state, data); + const lastIndex = state.notes[0].award_emoji.length - 1; + + expect(state.notes[0].award_emoji[lastIndex]).toEqual({ + name: 'cartwheel', + user: { id: userDataMock.id, name: userDataMock.name, username: userDataMock.username }, + }); + }); + + it('should remove award if user already reacted', () => { + const state = { + notes: [note], + userData: { + id: 1, + name: 'Administrator', + username: 'root', + }, + }; + + const data = { + note, + awardName: 'bath_tone3', + }; + mutations.TOGGLE_AWARD(state, data); + expect(state.notes[0].award_emoji.length).toEqual(2); + }); + }); + + describe('TOGGLE_DISCUSSION', () => { + it('should open a closed discussion', () => { + const discussion = Object.assign({}, discussionMock, { expanded: false }); + + const state = { + notes: [discussion], + }; + + mutations.TOGGLE_DISCUSSION(state, { discussionId: discussion.id }); + + expect(state.notes[0].expanded).toEqual(true); + }); + + it('should close a opened discussion', () => { + const state = { + notes: [discussionMock], + }; + + mutations.TOGGLE_DISCUSSION(state, { discussionId: discussionMock.id }); + + expect(state.notes[0].expanded).toEqual(false); + }); + }); + + describe('UPDATE_NOTE', () => { + it('should update a note', () => { + const state = { + notes: [individualNote], + }; + + const updated = Object.assign({}, individualNote.notes[0], { note: 'Foo' }); + + mutations.UPDATE_NOTE(state, updated); + + expect(state.notes[0].notes[0].note).toEqual('Foo'); + }); + }); +}); diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index 2c096ed08a8..8c5ad8914b0 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -32,14 +32,14 @@ import '~/notes'; describe('Notes', function() { const FLASH_TYPE_ALERT = 'alert'; - var commentsTemplate = 'issues/issue_with_comment.html.raw'; + var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw'; preloadFixtures(commentsTemplate); beforeEach(function () { loadFixtures(commentsTemplate); gl.utils.disableButtonIfEmptyField = _.noop; window.project_uploads_path = 'http://test.host/uploads'; - $('body').data('page', 'projects:issues:show'); + $('body').data('page', 'projects:merge_requets:show'); }); describe('task lists', function() { @@ -53,17 +53,19 @@ import '~/notes'; it('modifies the Markdown field', function() { const changeEvent = document.createEvent('HTMLEvents'); changeEvent.initEvent('change', true, true); - $('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent); - expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); + $('input[type=checkbox]').attr('checked', true)[1].dispatchEvent(changeEvent); + + expect($('.js-task-list-field.original-task-list').val()).toBe('- [x] Task List Item'); }); it('submits an ajax request on tasklist:changed', function() { spyOn(jQuery, 'ajax').and.callFake(function(req) { expect(req.type).toBe('PATCH'); - expect(req.url).toBe('http://test.host/frontend-fixtures/issues-project/notes/1'); + expect(req.url).toBe('http://test.host/frontend-fixtures/merge-requests-project/merge_requests/1.json'); return expect(req.data.note).not.toBe(null); }); - $('.js-task-list-field').trigger('tasklist:changed'); + + $('.js-task-list-field.js-note-text').trigger('tasklist:changed'); }); }); diff --git a/spec/javascripts/project_title_spec.js b/spec/javascripts/project_title_spec.js deleted file mode 100644 index 3d36bb3e4d4..00000000000 --- a/spec/javascripts/project_title_spec.js +++ /dev/null @@ -1,59 +0,0 @@ -/* global Project */ - -import 'select2/select2'; -import '~/gl_dropdown'; -import '~/api'; -import '~/project_select'; -import '~/project'; - -describe('Project Title', () => { - const dummyApiVersion = 'v3000'; - preloadFixtures('issues/open-issue.html.raw'); - loadJSONFixtures('projects.json'); - - beforeEach(() => { - loadFixtures('issues/open-issue.html.raw'); - - window.gon = {}; - window.gon.api_version = dummyApiVersion; - - // eslint-disable-next-line no-new - new Project(); - }); - - describe('project list', () => { - let reqUrl; - let reqData; - - beforeEach(() => { - const fakeResponseData = getJSONFixture('projects.json'); - spyOn(jQuery, 'ajax').and.callFake((req) => { - const def = $.Deferred(); - reqUrl = req.url; - reqData = req.data; - def.resolve(fakeResponseData); - return def.promise(); - }); - }); - - it('toggles dropdown', () => { - const $menu = $('.js-dropdown-menu-projects'); - window.gon.current_user_id = 1; - $('.js-projects-dropdown-toggle').click(); - expect($menu).toHaveClass('open'); - expect(reqUrl).toBe(`/api/${dummyApiVersion}/projects.json?simple=true`); - expect(reqData).toEqual({ - search: '', - order_by: 'last_activity_at', - per_page: 20, - membership: true, - }); - $menu.find('.dropdown-menu-close-icon').click(); - expect($menu).not.toHaveClass('open'); - }); - }); - - afterEach(() => { - window.gon = {}; - }); -}); diff --git a/spec/javascripts/projects_dropdown/components/app_spec.js b/spec/javascripts/projects_dropdown/components/app_spec.js new file mode 100644 index 00000000000..42f0f6fc1af --- /dev/null +++ b/spec/javascripts/projects_dropdown/components/app_spec.js @@ -0,0 +1,348 @@ +import Vue from 'vue'; + +import bp from '~/breakpoints'; +import appComponent from '~/projects_dropdown/components/app.vue'; +import eventHub from '~/projects_dropdown/event_hub'; +import ProjectsStore from '~/projects_dropdown/store/projects_store'; +import ProjectsService from '~/projects_dropdown/service/projects_service'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; +import { currentSession, mockProject, mockRawProject } from '../mock_data'; + +const createComponent = () => { + gon.api_version = currentSession.apiVersion; + const Component = Vue.extend(appComponent); + const store = new ProjectsStore(); + const service = new ProjectsService(currentSession.username); + + return mountComponent(Component, { + store, + service, + currentUserName: currentSession.username, + currentProject: currentSession.project, + }); +}; + +const returnServicePromise = (data, failed) => new Promise((resolve, reject) => { + if (failed) { + reject(data); + } else { + resolve({ + json() { + return data; + }, + }); + } +}); + +describe('AppComponent', () => { + describe('computed', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('frequentProjects', () => { + it('should return list of frequently accessed projects from store', () => { + expect(vm.frequentProjects).toBeDefined(); + expect(vm.frequentProjects.length).toBe(0); + + vm.store.setFrequentProjects([mockProject]); + expect(vm.frequentProjects).toBeDefined(); + expect(vm.frequentProjects.length).toBe(1); + }); + }); + + describe('searchProjects', () => { + it('should return list of frequently accessed projects from store', () => { + expect(vm.searchProjects).toBeDefined(); + expect(vm.searchProjects.length).toBe(0); + + vm.store.setSearchedProjects([mockRawProject]); + expect(vm.searchProjects).toBeDefined(); + expect(vm.searchProjects.length).toBe(1); + }); + }); + }); + + describe('methods', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('toggleFrequentProjectsList', () => { + it('should toggle props which control visibility of Frequent Projects list from state passed', () => { + vm.toggleFrequentProjectsList(true); + expect(vm.isLoadingProjects).toBeFalsy(); + expect(vm.isSearchListVisible).toBeFalsy(); + expect(vm.isFrequentsListVisible).toBeTruthy(); + + vm.toggleFrequentProjectsList(false); + expect(vm.isLoadingProjects).toBeTruthy(); + expect(vm.isSearchListVisible).toBeTruthy(); + expect(vm.isFrequentsListVisible).toBeFalsy(); + }); + }); + + describe('toggleSearchProjectsList', () => { + it('should toggle props which control visibility of Searched Projects list from state passed', () => { + vm.toggleSearchProjectsList(true); + expect(vm.isLoadingProjects).toBeFalsy(); + expect(vm.isFrequentsListVisible).toBeFalsy(); + expect(vm.isSearchListVisible).toBeTruthy(); + + vm.toggleSearchProjectsList(false); + expect(vm.isLoadingProjects).toBeTruthy(); + expect(vm.isFrequentsListVisible).toBeTruthy(); + expect(vm.isSearchListVisible).toBeFalsy(); + }); + }); + + describe('toggleLoader', () => { + it('should toggle props which control visibility of list loading animation from state passed', () => { + vm.toggleLoader(true); + expect(vm.isFrequentsListVisible).toBeFalsy(); + expect(vm.isSearchListVisible).toBeFalsy(); + expect(vm.isLoadingProjects).toBeTruthy(); + + vm.toggleLoader(false); + expect(vm.isFrequentsListVisible).toBeTruthy(); + expect(vm.isSearchListVisible).toBeTruthy(); + expect(vm.isLoadingProjects).toBeFalsy(); + }); + }); + + describe('fetchFrequentProjects', () => { + it('should set props for loading animation to `true` while frequent projects list is being loaded', () => { + spyOn(vm, 'toggleLoader'); + + vm.fetchFrequentProjects(); + expect(vm.isLocalStorageFailed).toBeFalsy(); + expect(vm.toggleLoader).toHaveBeenCalledWith(true); + }); + + it('should set props for loading animation to `false` and props for frequent projects list to `true` once data is loaded', () => { + const mockData = [mockProject]; + + spyOn(vm.service, 'getFrequentProjects').and.returnValue(mockData); + spyOn(vm.store, 'setFrequentProjects'); + spyOn(vm, 'toggleFrequentProjectsList'); + + vm.fetchFrequentProjects(); + expect(vm.service.getFrequentProjects).toHaveBeenCalled(); + expect(vm.store.setFrequentProjects).toHaveBeenCalledWith(mockData); + expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true); + }); + + it('should set props for failure message to `true` when method fails to fetch frequent projects list', () => { + spyOn(vm.service, 'getFrequentProjects').and.returnValue(null); + spyOn(vm.store, 'setFrequentProjects'); + spyOn(vm, 'toggleFrequentProjectsList'); + + expect(vm.isLocalStorageFailed).toBeFalsy(); + + vm.fetchFrequentProjects(); + expect(vm.service.getFrequentProjects).toHaveBeenCalled(); + expect(vm.store.setFrequentProjects).toHaveBeenCalledWith([]); + expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true); + expect(vm.isLocalStorageFailed).toBeTruthy(); + }); + + it('should set props for search results list to `true` if search query was already made previously', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('md'); + spyOn(vm.service, 'getFrequentProjects'); + spyOn(vm, 'toggleSearchProjectsList'); + + vm.searchQuery = 'test'; + vm.fetchFrequentProjects(); + expect(vm.service.getFrequentProjects).not.toHaveBeenCalled(); + expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true); + }); + + it('should set props for frequent projects list to `true` if search query was already made but screen size is less than 768px', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('sm'); + spyOn(vm, 'toggleSearchProjectsList'); + spyOn(vm.service, 'getFrequentProjects'); + + vm.searchQuery = 'test'; + vm.fetchFrequentProjects(); + expect(vm.service.getFrequentProjects).toHaveBeenCalled(); + expect(vm.toggleSearchProjectsList).not.toHaveBeenCalled(); + }); + }); + + describe('fetchSearchedProjects', () => { + const searchQuery = 'test'; + + it('should perform search with provided search query', (done) => { + const mockData = [mockRawProject]; + spyOn(vm, 'toggleLoader'); + spyOn(vm, 'toggleSearchProjectsList'); + spyOn(vm.service, 'getSearchedProjects').and.returnValue(returnServicePromise(mockData)); + spyOn(vm.store, 'setSearchedProjects'); + + vm.fetchSearchedProjects(searchQuery); + setTimeout(() => { + expect(vm.searchQuery).toBe(searchQuery); + expect(vm.toggleLoader).toHaveBeenCalledWith(true); + expect(vm.service.getSearchedProjects).toHaveBeenCalledWith(searchQuery); + expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true); + expect(vm.store.setSearchedProjects).toHaveBeenCalledWith(mockData); + done(); + }, 0); + }); + + it('should update props for showing search failure', (done) => { + spyOn(vm, 'toggleSearchProjectsList'); + spyOn(vm.service, 'getSearchedProjects').and.returnValue(returnServicePromise({}, true)); + + vm.fetchSearchedProjects(searchQuery); + setTimeout(() => { + expect(vm.searchQuery).toBe(searchQuery); + expect(vm.service.getSearchedProjects).toHaveBeenCalledWith(searchQuery); + expect(vm.isSearchFailed).toBeTruthy(); + expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true); + done(); + }, 0); + }); + }); + + describe('logCurrentProjectAccess', () => { + it('should log current project access via service', (done) => { + spyOn(vm.service, 'logProjectAccess'); + + vm.currentProject = mockProject; + vm.logCurrentProjectAccess(); + + setTimeout(() => { + expect(vm.service.logProjectAccess).toHaveBeenCalledWith(mockProject); + done(); + }, 1); + }); + }); + + describe('handleSearchClear', () => { + it('should show frequent projects list when search input is cleared', () => { + spyOn(vm.store, 'clearSearchedProjects'); + spyOn(vm, 'toggleFrequentProjectsList'); + + vm.handleSearchClear(); + + expect(vm.toggleFrequentProjectsList).toHaveBeenCalledWith(true); + expect(vm.store.clearSearchedProjects).toHaveBeenCalled(); + expect(vm.searchQuery).toBe(''); + }); + }); + + describe('handleSearchFailure', () => { + it('should show failure message within dropdown', () => { + spyOn(vm, 'toggleSearchProjectsList'); + + vm.handleSearchFailure(); + expect(vm.toggleSearchProjectsList).toHaveBeenCalledWith(true); + expect(vm.isSearchFailed).toBeTruthy(); + }); + }); + }); + + describe('created', () => { + it('should bind event listeners on eventHub', (done) => { + spyOn(eventHub, '$on'); + + createComponent().$mount(); + + Vue.nextTick(() => { + expect(eventHub.$on).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('searchProjects', jasmine.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('searchCleared', jasmine.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('searchFailed', jasmine.any(Function)); + done(); + }); + }); + }); + + describe('beforeDestroy', () => { + it('should unbind event listeners on eventHub', (done) => { + const vm = createComponent(); + spyOn(eventHub, '$off'); + + vm.$mount(); + vm.$destroy(); + + Vue.nextTick(() => { + expect(eventHub.$off).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('searchProjects', jasmine.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('searchCleared', jasmine.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('searchFailed', jasmine.any(Function)); + done(); + }); + }); + }); + + describe('template', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render search input', () => { + expect(vm.$el.querySelector('.search-input-container')).toBeDefined(); + }); + + it('should render loading animation', (done) => { + vm.toggleLoader(true); + Vue.nextTick(() => { + const loadingEl = vm.$el.querySelector('.loading-animation'); + + expect(loadingEl).toBeDefined(); + expect(loadingEl.classList.contains('prepend-top-20')).toBeTruthy(); + expect(loadingEl.querySelector('i').getAttribute('aria-label')).toBe('Loading projects'); + done(); + }); + }); + + it('should render frequent projects list header', (done) => { + vm.toggleFrequentProjectsList(true); + Vue.nextTick(() => { + const sectionHeaderEl = vm.$el.querySelector('.section-header'); + + expect(sectionHeaderEl).toBeDefined(); + expect(sectionHeaderEl.innerText.trim()).toBe('Frequently visited'); + done(); + }); + }); + + it('should render frequent projects list', (done) => { + vm.toggleFrequentProjectsList(true); + Vue.nextTick(() => { + expect(vm.$el.querySelector('.projects-list-frequent-container')).toBeDefined(); + done(); + }); + }); + + it('should render searched projects list', (done) => { + vm.toggleSearchProjectsList(true); + Vue.nextTick(() => { + expect(vm.$el.querySelector('.section-header')).toBe(null); + expect(vm.$el.querySelector('.projects-list-search-container')).toBeDefined(); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js new file mode 100644 index 00000000000..fcd0f6a3630 --- /dev/null +++ b/spec/javascripts/projects_dropdown/components/projects_list_frequent_spec.js @@ -0,0 +1,72 @@ +import Vue from 'vue'; + +import projectsListFrequentComponent from '~/projects_dropdown/components/projects_list_frequent.vue'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; +import { mockFrequents } from '../mock_data'; + +const createComponent = () => { + const Component = Vue.extend(projectsListFrequentComponent); + + return mountComponent(Component, { + projects: mockFrequents, + localStorageFailed: false, + }); +}; + +describe('ProjectsListFrequentComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('isListEmpty', () => { + it('should return `true` or `false` representing whether if `projects` is empty of not', () => { + vm.projects = []; + expect(vm.isListEmpty).toBeTruthy(); + + vm.projects = mockFrequents; + expect(vm.isListEmpty).toBeFalsy(); + }); + }); + + describe('listEmptyMessage', () => { + it('should return appropriate empty list message based on value of `localStorageFailed` prop', () => { + vm.localStorageFailed = true; + expect(vm.listEmptyMessage).toBe('This feature requires browser localStorage support'); + + vm.localStorageFailed = false; + expect(vm.listEmptyMessage).toBe('Projects you visit often will appear here'); + }); + }); + }); + + describe('template', () => { + it('should render component element with list of projects', (done) => { + vm.projects = mockFrequents; + + Vue.nextTick(() => { + expect(vm.$el.classList.contains('projects-list-frequent-container')).toBeTruthy(); + expect(vm.$el.querySelectorAll('ul.list-unstyled').length).toBe(1); + expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(5); + done(); + }); + }); + + it('should render component element with empty message', (done) => { + vm.projects = []; + + Vue.nextTick(() => { + expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1); + expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js new file mode 100644 index 00000000000..171629fcd6b --- /dev/null +++ b/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js @@ -0,0 +1,65 @@ +import Vue from 'vue'; + +import projectsListItemComponent from '~/projects_dropdown/components/projects_list_item.vue'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; +import { mockProject } from '../mock_data'; + +const createComponent = () => { + const Component = Vue.extend(projectsListItemComponent); + + return mountComponent(Component, { + projectId: mockProject.id, + projectName: mockProject.name, + namespace: mockProject.namespace, + webUrl: mockProject.webUrl, + avatarUrl: mockProject.avatarUrl, + }); +}; + +describe('ProjectsListItemComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('hasAvatar', () => { + it('should return `true` or `false` if whether avatar is present or not', () => { + vm.avatarUrl = 'path/to/avatar.png'; + expect(vm.hasAvatar).toBeTruthy(); + + vm.avatarUrl = null; + expect(vm.hasAvatar).toBeFalsy(); + }); + }); + + describe('highlightedProjectName', () => { + it('should enclose part of project name in <b> & </b> which matches with `matcher` prop', () => { + vm.matcher = 'lab'; + expect(vm.highlightedProjectName).toContain('<b>Lab</b>'); + }); + + it('should return project name as it is if `matcher` is not available', () => { + vm.matcher = null; + expect(vm.highlightedProjectName).toBe(mockProject.name); + }); + }); + }); + + describe('template', () => { + it('should render component element', () => { + expect(vm.$el.classList.contains('projects-list-item-container')).toBeTruthy(); + expect(vm.$el.querySelectorAll('a').length).toBe(1); + expect(vm.$el.querySelectorAll('.project-item-avatar-container').length).toBe(1); + expect(vm.$el.querySelectorAll('.project-item-metadata-container').length).toBe(1); + expect(vm.$el.querySelectorAll('.project-title').length).toBe(1); + expect(vm.$el.querySelectorAll('.project-namespace').length).toBe(1); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js new file mode 100644 index 00000000000..59fc2dedba5 --- /dev/null +++ b/spec/javascripts/projects_dropdown/components/projects_list_search_spec.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; + +import projectsListSearchComponent from '~/projects_dropdown/components/projects_list_search.vue'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; +import { mockProject } from '../mock_data'; + +const createComponent = () => { + const Component = Vue.extend(projectsListSearchComponent); + + return mountComponent(Component, { + projects: [mockProject], + matcher: 'lab', + searchFailed: false, + }); +}; + +describe('ProjectsListSearchComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('isListEmpty', () => { + it('should return `true` or `false` representing whether if `projects` is empty of not', () => { + vm.projects = []; + expect(vm.isListEmpty).toBeTruthy(); + + vm.projects = [mockProject]; + expect(vm.isListEmpty).toBeFalsy(); + }); + }); + + describe('listEmptyMessage', () => { + it('should return appropriate empty list message based on value of `searchFailed` prop', () => { + vm.searchFailed = true; + expect(vm.listEmptyMessage).toBe('Something went wrong on our end.'); + + vm.searchFailed = false; + expect(vm.listEmptyMessage).toBe('No projects matched your query'); + }); + }); + }); + + describe('template', () => { + it('should render component element with list of projects', (done) => { + vm.projects = [mockProject]; + + Vue.nextTick(() => { + expect(vm.$el.classList.contains('projects-list-search-container')).toBeTruthy(); + expect(vm.$el.querySelectorAll('ul.list-unstyled').length).toBe(1); + expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(1); + done(); + }); + }); + + it('should render component element with empty message', (done) => { + vm.projects = []; + + Vue.nextTick(() => { + expect(vm.$el.querySelectorAll('li.section-empty').length).toBe(1); + expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0); + done(); + }); + }); + + it('should render component element with failure message', (done) => { + vm.searchFailed = true; + vm.projects = []; + + Vue.nextTick(() => { + expect(vm.$el.querySelectorAll('li.section-empty.section-failure').length).toBe(1); + expect(vm.$el.querySelectorAll('li.projects-list-item-container').length).toBe(0); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/components/search_spec.js b/spec/javascripts/projects_dropdown/components/search_spec.js new file mode 100644 index 00000000000..f2a23e33325 --- /dev/null +++ b/spec/javascripts/projects_dropdown/components/search_spec.js @@ -0,0 +1,101 @@ +import Vue from 'vue'; + +import searchComponent from '~/projects_dropdown/components/search.vue'; +import eventHub from '~/projects_dropdown/event_hub'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const createComponent = () => { + const Component = Vue.extend(searchComponent); + + return mountComponent(Component); +}; + +describe('SearchComponent', () => { + describe('methods', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('setFocus', () => { + it('should set focus to search input', () => { + spyOn(vm.$refs.search, 'focus'); + + vm.setFocus(); + expect(vm.$refs.search.focus).toHaveBeenCalled(); + }); + }); + + describe('emitSearchEvents', () => { + it('should emit `searchProjects` event via eventHub when `searchQuery` present', () => { + const searchQuery = 'test'; + spyOn(eventHub, '$emit'); + vm.searchQuery = searchQuery; + vm.emitSearchEvents(); + expect(eventHub.$emit).toHaveBeenCalledWith('searchProjects', searchQuery); + }); + + it('should emit `searchCleared` event via eventHub when `searchQuery` is cleared', () => { + spyOn(eventHub, '$emit'); + vm.searchQuery = ''; + vm.emitSearchEvents(); + expect(eventHub.$emit).toHaveBeenCalledWith('searchCleared'); + }); + }); + }); + + describe('mounted', () => { + it('should listen `dropdownOpen` event', (done) => { + spyOn(eventHub, '$on'); + createComponent(); + + Vue.nextTick(() => { + expect(eventHub.$on).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function)); + done(); + }); + }); + }); + + describe('beforeDestroy', () => { + it('should unbind event listeners on eventHub', (done) => { + const vm = createComponent(); + spyOn(eventHub, '$off'); + + vm.$mount(); + vm.$destroy(); + + Vue.nextTick(() => { + expect(eventHub.$off).toHaveBeenCalledWith('dropdownOpen', jasmine.any(Function)); + done(); + }); + }); + }); + + describe('template', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render component element', () => { + const inputEl = vm.$el.querySelector('input.form-control'); + + expect(vm.$el.classList.contains('search-input-container')).toBeTruthy(); + expect(vm.$el.classList.contains('hidden-xs')).toBeTruthy(); + expect(inputEl).not.toBe(null); + expect(inputEl.getAttribute('placeholder')).toBe('Search projects'); + expect(vm.$el.querySelector('.search-icon')).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/mock_data.js b/spec/javascripts/projects_dropdown/mock_data.js new file mode 100644 index 00000000000..d6a79fb8ac1 --- /dev/null +++ b/spec/javascripts/projects_dropdown/mock_data.js @@ -0,0 +1,96 @@ +export const currentSession = { + username: 'root', + storageKey: 'root/frequent-projects', + apiVersion: 'v4', + project: { + id: 1, + name: 'dummy-project', + namespace: 'SamepleGroup / Dummy-Project', + webUrl: 'http://127.0.0.1/samplegroup/dummy-project', + avatarUrl: null, + lastAccessedOn: Date.now(), + }, +}; + +export const mockProject = { + id: 1, + name: 'GitLab Community Edition', + namespace: 'gitlab-org / gitlab-ce', + webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce', + avatarUrl: null, +}; + +export const mockRawProject = { + id: 1, + name: 'GitLab Community Edition', + name_with_namespace: 'gitlab-org / gitlab-ce', + web_url: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce', + avatar_url: null, +}; + +export const mockFrequents = [ + { + id: 1, + name: 'GitLab Community Edition', + namespace: 'gitlab-org / gitlab-ce', + webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ce', + avatarUrl: null, + }, + { + id: 2, + name: 'GitLab CI', + namespace: 'gitlab-org / gitlab-ci', + webUrl: 'http://127.0.0.1:3000/gitlab-org/gitlab-ci', + avatarUrl: null, + }, + { + id: 3, + name: 'Typeahead.Js', + namespace: 'twitter / typeahead-js', + webUrl: 'http://127.0.0.1:3000/twitter/typeahead-js', + avatarUrl: '/uploads/-/system/project/avatar/7/TWBS.png', + }, + { + id: 4, + name: 'Intel', + namespace: 'platform / hardware / bsp / intel', + webUrl: 'http://127.0.0.1:3000/platform/hardware/bsp/intel', + avatarUrl: null, + }, + { + id: 5, + name: 'v4.4', + namespace: 'platform / hardware / bsp / kernel / common / v4.4', + webUrl: 'http://localhost:3000/platform/hardware/bsp/kernel/common/v4.4', + avatarUrl: null, + }, +]; + +export const unsortedFrequents = [ + { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, + { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, + { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, + { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, + { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, + { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, + { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, + { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, + { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, +]; + +/** + * This const has a specific order which tests authenticity + * of `ProjectsService.getTopFrequentProjects` method so + * DO NOT change order of items in this const. + */ +export const sortedFrequents = [ + { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, + { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, + { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, + { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, + { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, + { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, + { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, + { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, + { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, +]; diff --git a/spec/javascripts/projects_dropdown/service/projects_service_spec.js b/spec/javascripts/projects_dropdown/service/projects_service_spec.js new file mode 100644 index 00000000000..d5dd8b3449a --- /dev/null +++ b/spec/javascripts/projects_dropdown/service/projects_service_spec.js @@ -0,0 +1,179 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +import bp from '~/breakpoints'; +import ProjectsService from '~/projects_dropdown/service/projects_service'; +import { FREQUENT_PROJECTS } from '~/projects_dropdown/constants'; +import { currentSession, unsortedFrequents, sortedFrequents } from '../mock_data'; + +Vue.use(VueResource); + +FREQUENT_PROJECTS.MAX_COUNT = 3; + +describe('ProjectsService', () => { + let service; + + beforeEach(() => { + gon.api_version = currentSession.apiVersion; + gon.current_user_id = 1; + service = new ProjectsService(currentSession.username); + }); + + describe('contructor', () => { + it('should initialize default properties of class', () => { + expect(service.isLocalStorageAvailable).toBeTruthy(); + expect(service.currentUserName).toBe(currentSession.username); + expect(service.storageKey).toBe(currentSession.storageKey); + expect(service.projectsPath).toBeDefined(); + }); + }); + + describe('getSearchedProjects', () => { + it('should return promise from VueResource HTTP GET', () => { + spyOn(service.projectsPath, 'get').and.stub(); + + const searchQuery = 'lab'; + const queryParams = { + simple: false, + per_page: 20, + membership: true, + order_by: 'last_activity_at', + search: searchQuery, + }; + + service.getSearchedProjects(searchQuery); + expect(service.projectsPath.get).toHaveBeenCalledWith(queryParams); + }); + }); + + describe('logProjectAccess', () => { + let storage; + + beforeEach(() => { + storage = {}; + + spyOn(window.localStorage, 'setItem').and.callFake((storageKey, value) => { + storage[storageKey] = value; + }); + + spyOn(window.localStorage, 'getItem').and.callFake((storageKey) => { + if (storage[storageKey]) { + return storage[storageKey]; + } + + return null; + }); + }); + + it('should create a project store if it does not exist and adds a project', () => { + service.logProjectAccess(currentSession.project); + + const projects = JSON.parse(storage[currentSession.storageKey]); + expect(projects.length).toBe(1); + expect(projects[0].frequency).toBe(1); + expect(projects[0].lastAccessedOn).toBeDefined(); + }); + + it('should prevent inserting same report multiple times into store', () => { + service.logProjectAccess(currentSession.project); + service.logProjectAccess(currentSession.project); + + const projects = JSON.parse(storage[currentSession.storageKey]); + expect(projects.length).toBe(1); + }); + + it('should increase frequency of report if it was logged multiple times over the course of an hour', () => { + let projects; + spyOn(Math, 'abs').and.returnValue(3600001); // this will lead to `diff` > 1; + service.logProjectAccess(currentSession.project); + + projects = JSON.parse(storage[currentSession.storageKey]); + expect(projects[0].frequency).toBe(1); + + service.logProjectAccess(currentSession.project); + projects = JSON.parse(storage[currentSession.storageKey]); + expect(projects[0].frequency).toBe(2); + expect(projects[0].lastAccessedOn).not.toBe(currentSession.project.lastAccessedOn); + }); + + it('should always update project metadata', () => { + let projects; + const oldProject = { + ...currentSession.project, + }; + + const newProject = { + ...currentSession.project, + name: 'New Name', + avatarUrl: 'new/avatar.png', + namespace: 'New / Namespace', + webUrl: 'http://localhost/new/web/url', + }; + + service.logProjectAccess(oldProject); + projects = JSON.parse(storage[currentSession.storageKey]); + expect(projects[0].name).toBe(oldProject.name); + expect(projects[0].avatarUrl).toBe(oldProject.avatarUrl); + expect(projects[0].namespace).toBe(oldProject.namespace); + expect(projects[0].webUrl).toBe(oldProject.webUrl); + + service.logProjectAccess(newProject); + projects = JSON.parse(storage[currentSession.storageKey]); + expect(projects[0].name).toBe(newProject.name); + expect(projects[0].avatarUrl).toBe(newProject.avatarUrl); + expect(projects[0].namespace).toBe(newProject.namespace); + expect(projects[0].webUrl).toBe(newProject.webUrl); + }); + + it('should not add more than 20 projects in store', () => { + for (let i = 1; i <= 5; i += 1) { + const project = Object.assign(currentSession.project, { id: i }); + service.logProjectAccess(project); + } + + const projects = JSON.parse(storage[currentSession.storageKey]); + expect(projects.length).toBe(3); + }); + }); + + describe('getTopFrequentProjects', () => { + let storage = {}; + + beforeEach(() => { + storage[currentSession.storageKey] = JSON.stringify(unsortedFrequents); + + spyOn(window.localStorage, 'getItem').and.callFake((storageKey) => { + if (storage[storageKey]) { + return storage[storageKey]; + } + + return null; + }); + }); + + it('should return top 5 frequently accessed projects for desktop screens', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('md'); + const frequentProjects = service.getTopFrequentProjects(); + + expect(frequentProjects.length).toBe(5); + frequentProjects.forEach((project, index) => { + expect(project.id).toBe(sortedFrequents[index].id); + }); + }); + + it('should return top 3 frequently accessed projects for mobile screens', () => { + spyOn(bp, 'getBreakpointSize').and.returnValue('sm'); + const frequentProjects = service.getTopFrequentProjects(); + + expect(frequentProjects.length).toBe(3); + frequentProjects.forEach((project, index) => { + expect(project.id).toBe(sortedFrequents[index].id); + }); + }); + + it('should return empty array if there are no projects available in store', () => { + storage = {}; + expect(service.getTopFrequentProjects().length).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/projects_dropdown/store/projects_store_spec.js b/spec/javascripts/projects_dropdown/store/projects_store_spec.js new file mode 100644 index 00000000000..e57399d37cd --- /dev/null +++ b/spec/javascripts/projects_dropdown/store/projects_store_spec.js @@ -0,0 +1,41 @@ +import ProjectsStore from '~/projects_dropdown/store/projects_store'; +import { mockProject, mockRawProject } from '../mock_data'; + +describe('ProjectsStore', () => { + let store; + + beforeEach(() => { + store = new ProjectsStore(); + }); + + describe('setFrequentProjects', () => { + it('should set frequent projects list to state', () => { + store.setFrequentProjects([mockProject]); + + expect(store.getFrequentProjects().length).toBe(1); + expect(store.getFrequentProjects()[0].id).toBe(mockProject.id); + }); + }); + + describe('setSearchedProjects', () => { + it('should set searched projects list to state', () => { + store.setSearchedProjects([mockRawProject]); + + const processedProjects = store.getSearchedProjects(); + expect(processedProjects.length).toBe(1); + expect(processedProjects[0].id).toBe(mockRawProject.id); + expect(processedProjects[0].namespace).toBe(mockRawProject.name_with_namespace); + expect(processedProjects[0].webUrl).toBe(mockRawProject.web_url); + expect(processedProjects[0].avatarUrl).toBe(mockRawProject.avatar_url); + }); + }); + + describe('clearSearchedProjects', () => { + it('should clear searched projects list from state', () => { + store.setSearchedProjects([mockRawProject]); + expect(store.getSearchedProjects().length).toBe(1); + store.clearSearchedProjects(); + expect(store.getSearchedProjects().length).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js index 3515dfbc60b..a912e150e9b 100644 --- a/spec/javascripts/shortcuts_issuable_spec.js +++ b/spec/javascripts/shortcuts_issuable_spec.js @@ -1,78 +1,74 @@ -/* eslint-disable space-before-function-paren, no-return-assign, no-var, quotes */ /* global ShortcutsIssuable */ import '~/copy_as_gfm'; import '~/shortcuts_issuable'; -(function() { - describe('ShortcutsIssuable', function() { - var fixtureName = 'issues/open-issue.html.raw'; - preloadFixtures(fixtureName); - beforeEach(function() { - loadFixtures(fixtureName); - document.querySelector('.js-new-note-form').classList.add('js-main-target-form'); - this.shortcut = new ShortcutsIssuable(); - }); - describe('replyWithSelectedText', function() { - var stubSelection; - // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML. - stubSelection = function(html) { - window.gl.utils.getSelectedFragment = function() { - var node = document.createElement('div'); - node.innerHTML = html; - return node; - }; +describe('ShortcutsIssuable', () => { + const fixtureName = 'merge_requests/diff_comment.html.raw'; + preloadFixtures(fixtureName); + beforeEach(() => { + loadFixtures(fixtureName); + document.querySelector('.js-new-note-form').classList.add('js-main-target-form'); + this.shortcut = new ShortcutsIssuable(true); + }); + describe('replyWithSelectedText', () => { + // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML. + const stubSelection = (html) => { + window.gl.utils.getSelectedFragment = () => { + const node = document.createElement('div'); + node.innerHTML = html; + return node; }; - beforeEach(function() { - this.selector = 'form.js-main-target-form textarea#note_note'; + }; + beforeEach(() => { + this.selector = '.js-main-target-form #note_note'; + }); + describe('with empty selection', () => { + it('does not return an error', () => { + this.shortcut.replyWithSelectedText(true); + expect($(this.selector).val()).toBe(''); }); - describe('with empty selection', function() { - it('does not return an error', function() { - this.shortcut.replyWithSelectedText(); - expect($(this.selector).val()).toBe(''); - }); - it('triggers `focus`', function() { - this.shortcut.replyWithSelectedText(); - expect(document.activeElement).toBe(document.querySelector(this.selector)); - }); + it('triggers `focus`', () => { + this.shortcut.replyWithSelectedText(true); + expect(document.activeElement).toBe(document.querySelector(this.selector)); }); - describe('with any selection', function() { - beforeEach(function() { - stubSelection('<p>Selected text.</p>'); - }); - it('leaves existing input intact', function() { - $(this.selector).val('This text was already here.'); - expect($(this.selector).val()).toBe('This text was already here.'); - this.shortcut.replyWithSelectedText(); - expect($(this.selector).val()).toBe("This text was already here.\n\n> Selected text.\n\n"); - }); - it('triggers `input`', function() { - var triggered = false; - $(this.selector).on('input', function() { - triggered = true; - }); - this.shortcut.replyWithSelectedText(); - expect(triggered).toBe(true); - }); - it('triggers `focus`', function() { - this.shortcut.replyWithSelectedText(); - expect(document.activeElement).toBe(document.querySelector(this.selector)); - }); + }); + describe('with any selection', () => { + beforeEach(() => { + stubSelection('<p>Selected text.</p>'); }); - describe('with a one-line selection', function() { - it('quotes the selection', function() { - stubSelection('<p>This text has been selected.</p>'); - this.shortcut.replyWithSelectedText(); - expect($(this.selector).val()).toBe("> This text has been selected.\n\n"); - }); + it('leaves existing input intact', () => { + $(this.selector).val('This text was already here.'); + expect($(this.selector).val()).toBe('This text was already here.'); + this.shortcut.replyWithSelectedText(true); + expect($(this.selector).val()).toBe('This text was already here.\n\n> Selected text.\n\n'); }); - describe('with a multi-line selection', function() { - it('quotes the selected lines as a group', function() { - stubSelection("<p>Selected line one.</p>\n\n<p>Selected line two.</p>\n\n<p>Selected line three.</p>"); - this.shortcut.replyWithSelectedText(); - expect($(this.selector).val()).toBe("> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n"); + it('triggers `input`', () => { + let triggered = false; + $(this.selector).on('input', () => { + triggered = true; }); + this.shortcut.replyWithSelectedText(true); + expect(triggered).toBe(true); + }); + it('triggers `focus`', () => { + this.shortcut.replyWithSelectedText(true); + expect(document.activeElement).toBe(document.querySelector(this.selector)); + }); + }); + describe('with a one-line selection', () => { + it('quotes the selection', () => { + stubSelection('<p>This text has been selected.</p>'); + this.shortcut.replyWithSelectedText(true); + expect($(this.selector).val()).toBe('> This text has been selected.\n\n'); + }); + }); + describe('with a multi-line selection', () => { + it('quotes the selected lines as a group', () => { + stubSelection('<p>Selected line one.</p>\n\n<p>Selected line two.</p>\n\n<p>Selected line three.</p>'); + this.shortcut.replyWithSelectedText(true); + expect($(this.selector).val()).toBe('> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n'); }); }); }); -}).call(window); +}); diff --git a/spec/javascripts/shortcuts_spec.js b/spec/javascripts/shortcuts_spec.js index 9b8373df29e..53e4c68beb3 100644 --- a/spec/javascripts/shortcuts_spec.js +++ b/spec/javascripts/shortcuts_spec.js @@ -1,6 +1,6 @@ /* global Shortcuts */ describe('Shortcuts', () => { - const fixtureName = 'issues/issue_with_comment.html.raw'; + const fixtureName = 'merge_requests/diff_comment.html.raw'; const createEvent = (type, target) => $.Event(type, { target, }); diff --git a/spec/javascripts/sidebar/mock_data.js b/spec/javascripts/sidebar/mock_data.js index 9fc8667ecc9..e2b6bcabc98 100644 --- a/spec/javascripts/sidebar/mock_data.js +++ b/spec/javascripts/sidebar/mock_data.js @@ -66,17 +66,57 @@ const sidebarMockData = { }, labels: [], }, + '/autocomplete/projects?project_id=15': [ + { + 'id': 0, + 'name_with_namespace': 'No project', + }, { + 'id': 20, + 'name_with_namespace': 'foo / bar', + }, + ], }, 'PUT': { '/gitlab-org/gitlab-shell/issues/5.json': { data: {}, }, }, + 'POST': { + '/gitlab-org/gitlab-shell/issues/5/move': { + id: 123, + iid: 5, + author_id: 1, + description: 'some description', + lock_version: 5, + milestone_id: null, + state: 'opened', + title: 'some title', + updated_by_id: 1, + created_at: '2017-06-27T19:54:42.437Z', + updated_at: '2017-08-18T03:39:49.222Z', + deleted_at: null, + time_estimate: 0, + total_time_spent: 0, + human_time_estimate: null, + human_total_time_spent: null, + branch_name: null, + confidential: false, + assignees: [], + due_date: null, + moved_to_id: null, + project_id: 7, + milestone: null, + labels: [], + web_url: '/root/some-project/issues/5', + }, + }, }; export default { mediator: { endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move', + projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15', editable: true, currentUser: { id: 1, @@ -85,6 +125,7 @@ export default { avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', }, rootPath: '/', + fullPath: '/gitlab-org/gitlab-shell', }, time: { time_estimate: 3600, diff --git a/spec/javascripts/sidebar/sidebar_mediator_spec.js b/spec/javascripts/sidebar/sidebar_mediator_spec.js index e246f41ee82..3aa8ca5db0d 100644 --- a/spec/javascripts/sidebar/sidebar_mediator_spec.js +++ b/spec/javascripts/sidebar/sidebar_mediator_spec.js @@ -30,7 +30,7 @@ describe('Sidebar mediator', () => { expect(resp.status).toEqual(200); done(); }) - .catch(() => {}); + .catch(done.fail); }); it('fetches the data', () => { @@ -38,4 +38,42 @@ describe('Sidebar mediator', () => { this.mediator.fetch(); expect(this.mediator.service.get).toHaveBeenCalled(); }); + + it('sets moveToProjectId', () => { + const projectId = 7; + spyOn(this.mediator.store, 'setMoveToProjectId').and.callThrough(); + + this.mediator.setMoveToProjectId(projectId); + + expect(this.mediator.store.setMoveToProjectId).toHaveBeenCalledWith(projectId); + }); + + it('fetches autocomplete projects', (done) => { + const searchTerm = 'foo'; + spyOn(this.mediator.service, 'getProjectsAutocomplete').and.callThrough(); + spyOn(this.mediator.store, 'setAutocompleteProjects').and.callThrough(); + + this.mediator.fetchAutocompleteProjects(searchTerm) + .then(() => { + expect(this.mediator.service.getProjectsAutocomplete).toHaveBeenCalledWith(searchTerm); + expect(this.mediator.store.setAutocompleteProjects).toHaveBeenCalled(); + done(); + }) + .catch(done.fail); + }); + + it('moves issue', (done) => { + const moveToProjectId = 7; + this.mediator.store.setMoveToProjectId(moveToProjectId); + spyOn(this.mediator.service, 'moveIssue').and.callThrough(); + spyOn(gl.utils, 'visitUrl'); + + this.mediator.moveIssue() + .then(() => { + expect(this.mediator.service.moveIssue).toHaveBeenCalledWith(moveToProjectId); + expect(gl.utils.visitUrl).toHaveBeenCalledWith('/root/some-project/issues/5'); + done(); + }) + .catch(done.fail); + }); }); diff --git a/spec/javascripts/sidebar/sidebar_move_issue_spec.js b/spec/javascripts/sidebar/sidebar_move_issue_spec.js new file mode 100644 index 00000000000..8b0d51bbcc8 --- /dev/null +++ b/spec/javascripts/sidebar/sidebar_move_issue_spec.js @@ -0,0 +1,142 @@ +import Vue from 'vue'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarMoveIssue from '~/sidebar/lib/sidebar_move_issue'; +import Mock from './mock_data'; + +describe('SidebarMoveIssue', () => { + beforeEach(() => { + Vue.http.interceptors.push(Mock.sidebarMockInterceptor); + this.mediator = new SidebarMediator(Mock.mediator); + this.$content = $(` + <div class="dropdown"> + <div class="js-toggle"></div> + <div class="dropdown-content"></div> + <div class="js-confirm-button"></div> + </div> + `); + this.$toggleButton = this.$content.find('.js-toggle'); + this.$confirmButton = this.$content.find('.js-confirm-button'); + + this.sidebarMoveIssue = new SidebarMoveIssue( + this.mediator, + this.$toggleButton, + this.$confirmButton, + ); + this.sidebarMoveIssue.init(); + }); + + afterEach(() => { + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + + this.sidebarMoveIssue.destroy(); + + Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor); + }); + + describe('init', () => { + it('should initialize the dropdown and listeners', () => { + spyOn(this.sidebarMoveIssue, 'initDropdown'); + spyOn(this.sidebarMoveIssue, 'addEventListeners'); + + this.sidebarMoveIssue.init(); + + expect(this.sidebarMoveIssue.initDropdown).toHaveBeenCalled(); + expect(this.sidebarMoveIssue.addEventListeners).toHaveBeenCalled(); + }); + }); + + describe('destroy', () => { + it('should remove the listeners', () => { + spyOn(this.sidebarMoveIssue, 'removeEventListeners'); + + this.sidebarMoveIssue.destroy(); + + expect(this.sidebarMoveIssue.removeEventListeners).toHaveBeenCalled(); + }); + }); + + describe('initDropdown', () => { + it('should initialize the gl_dropdown', () => { + spyOn($.fn, 'glDropdown'); + + this.sidebarMoveIssue.initDropdown(); + + expect($.fn.glDropdown).toHaveBeenCalled(); + }); + }); + + describe('onConfirmClicked', () => { + it('should move the issue with valid project ID', () => { + spyOn(this.mediator, 'moveIssue').and.returnValue(Promise.resolve()); + this.mediator.setMoveToProjectId(7); + + this.sidebarMoveIssue.onConfirmClicked(); + + expect(this.mediator.moveIssue).toHaveBeenCalled(); + expect(this.$confirmButton.attr('disabled')).toBe('disabled'); + expect(this.$confirmButton.hasClass('is-loading')).toBe(true); + }); + + it('should remove loading state from confirm button on failure', (done) => { + spyOn(window, 'Flash'); + spyOn(this.mediator, 'moveIssue').and.returnValue(Promise.reject()); + this.mediator.setMoveToProjectId(7); + + this.sidebarMoveIssue.onConfirmClicked(); + + expect(this.mediator.moveIssue).toHaveBeenCalled(); + // Wait for the move issue request to fail + setTimeout(() => { + expect(window.Flash).toHaveBeenCalled(); + expect(this.$confirmButton.attr('disabled')).toBe(undefined); + expect(this.$confirmButton.hasClass('is-loading')).toBe(false); + done(); + }); + }); + + it('should not move the issue with id=0', () => { + spyOn(this.mediator, 'moveIssue'); + this.mediator.setMoveToProjectId(0); + + this.sidebarMoveIssue.onConfirmClicked(); + + expect(this.mediator.moveIssue).not.toHaveBeenCalled(); + }); + }); + + it('should set moveToProjectId on dropdown item "No project" click', (done) => { + spyOn(this.mediator, 'setMoveToProjectId'); + + // Open the dropdown + this.$toggleButton.dropdown('toggle'); + + // Wait for the autocomplete request to finish + setTimeout(() => { + this.$content.find('.js-move-issue-dropdown-item').eq(0).trigger('click'); + + expect(this.mediator.setMoveToProjectId).toHaveBeenCalledWith(0); + expect(this.$confirmButton.attr('disabled')).toBe('disabled'); + done(); + }, 0); + }); + + it('should set moveToProjectId on dropdown item click', (done) => { + spyOn(this.mediator, 'setMoveToProjectId'); + + // Open the dropdown + this.$toggleButton.dropdown('toggle'); + + // Wait for the autocomplete request to finish + setTimeout(() => { + this.$content.find('.js-move-issue-dropdown-item').eq(1).trigger('click'); + + expect(this.mediator.setMoveToProjectId).toHaveBeenCalledWith(20); + expect(this.$confirmButton.attr('disabled')).toBe(undefined); + done(); + }, 0); + }); +}); diff --git a/spec/javascripts/sidebar/sidebar_service_spec.js b/spec/javascripts/sidebar/sidebar_service_spec.js index 91a4dd669a7..a4bd8ba8d88 100644 --- a/spec/javascripts/sidebar/sidebar_service_spec.js +++ b/spec/javascripts/sidebar/sidebar_service_spec.js @@ -5,7 +5,11 @@ import Mock from './mock_data'; describe('Sidebar service', () => { beforeEach(() => { Vue.http.interceptors.push(Mock.sidebarMockInterceptor); - this.service = new SidebarService('/gitlab-org/gitlab-shell/issues/5.json'); + this.service = new SidebarService({ + endpoint: '/gitlab-org/gitlab-shell/issues/5.json', + moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move', + projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15', + }); }); afterEach(() => { @@ -19,7 +23,7 @@ describe('Sidebar service', () => { expect(resp).toBeDefined(); done(); }) - .catch(() => {}); + .catch(done.fail); }); it('updates the data', (done) => { @@ -28,6 +32,24 @@ describe('Sidebar service', () => { expect(resp).toBeDefined(); done(); }) - .catch(() => {}); + .catch(done.fail); + }); + + it('gets projects for autocomplete', (done) => { + this.service.getProjectsAutocomplete() + .then((resp) => { + expect(resp).toBeDefined(); + done(); + }) + .catch(done.fail); + }); + + it('moves the issue to another project', (done) => { + this.service.moveIssue(123) + .then((resp) => { + expect(resp).toBeDefined(); + done(); + }) + .catch(done.fail); }); }); diff --git a/spec/javascripts/sidebar/sidebar_store_spec.js b/spec/javascripts/sidebar/sidebar_store_spec.js index b3fa156eb64..69eb3839d67 100644 --- a/spec/javascripts/sidebar/sidebar_store_spec.js +++ b/spec/javascripts/sidebar/sidebar_store_spec.js @@ -82,4 +82,18 @@ describe('Sidebar store', () => { expect(this.store.humanTimeEstimate).toEqual(Mock.time.human_time_estimate); expect(this.store.humanTotalTimeSpent).toEqual(Mock.time.human_total_time_spent); }); + + it('set autocomplete projects', () => { + const projects = [{ id: 0 }]; + this.store.setAutocompleteProjects(projects); + + expect(this.store.autocompleteProjects).toEqual(projects); + }); + + it('set move to project ID', () => { + const projectId = 7; + this.store.setMoveToProjectId(projectId); + + expect(this.store.moveToProjectId).toEqual(projectId); + }); }); diff --git a/spec/javascripts/vue_shared/components/identicon_spec.js b/spec/javascripts/vue_shared/components/identicon_spec.js index 4f194e5a64e..647680f00f7 100644 --- a/spec/javascripts/vue_shared/components/identicon_spec.js +++ b/spec/javascripts/vue_shared/components/identicon_spec.js @@ -1,25 +1,30 @@ import Vue from 'vue'; import identiconComponent from '~/vue_shared/components/identicon.vue'; -const createComponent = () => { +const createComponent = (sizeClass) => { const Component = Vue.extend(identiconComponent); return new Component({ propsData: { entityId: 1, entityName: 'entity-name', + sizeClass, }, }).$mount(); }; describe('IdenticonComponent', () => { - let vm; + describe('computed', () => { + let vm; - beforeEach(() => { - vm = createComponent(); - }); + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); - describe('computed', () => { describe('identiconStyles', () => { it('should return styles attribute value with `background-color` property', () => { vm.entityId = 4; @@ -48,9 +53,20 @@ describe('IdenticonComponent', () => { describe('template', () => { it('should render identicon', () => { + const vm = createComponent(); + expect(vm.$el.nodeName).toBe('DIV'); expect(vm.$el.classList.contains('identicon')).toBeTruthy(); + expect(vm.$el.classList.contains('s40')).toBeTruthy(); expect(vm.$el.getAttribute('style').indexOf('background-color') > -1).toBeTruthy(); + vm.$destroy(); + }); + + it('should render identicon with provided sizing class', () => { + const vm = createComponent('s32'); + + expect(vm.$el.classList.contains('s32')).toBeTruthy(); + vm.$destroy(); }); }); }); diff --git a/spec/javascripts/vue_shared/components/issue/confidential_issue_warning_spec.js b/spec/javascripts/vue_shared/components/issue/confidential_issue_warning_spec.js new file mode 100644 index 00000000000..6df08f3ebe7 --- /dev/null +++ b/spec/javascripts/vue_shared/components/issue/confidential_issue_warning_spec.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import confidentialIssue from '~/vue_shared/components/issue/confidential_issue_warning.vue'; + +describe('Confidential Issue Warning Component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(confidentialIssue); + vm = new Component().$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render confidential issue warning information', () => { + expect(vm.$el.querySelector('i').className).toEqual('fa fa-eye-slash'); + expect(vm.$el.querySelector('span').textContent.trim()).toEqual('This is a confidential issue. Your comment will not be visible to the public.'); + }); +}); diff --git a/spec/javascripts/vue_shared/components/markdown/field_spec.js b/spec/javascripts/vue_shared/components/markdown/field_spec.js index 291e19c9f3c..60a5c2ae74e 100644 --- a/spec/javascripts/vue_shared/components/markdown/field_spec.js +++ b/spec/javascripts/vue_shared/components/markdown/field_spec.js @@ -16,8 +16,8 @@ describe('Markdown field component', () => { }, template: ` <field-component - marodown-preview-url="/preview" - markdown-docs="/docs" + markdown-preview-path="/preview" + markdown-docs-path="/docs" > <textarea slot="textarea" @@ -92,6 +92,7 @@ describe('Markdown field component', () => { it('renders GFM with jQuery', (done) => { spyOn($.fn, 'renderGFM'); + previewLink.click(); setTimeout(() => { @@ -100,7 +101,7 @@ describe('Markdown field component', () => { ).toHaveBeenCalled(); done(); - }); + }, 0); }); }); diff --git a/spec/javascripts/zen_mode_spec.js b/spec/javascripts/zen_mode_spec.js index a225b04c47e..bd18f79cea7 100644 --- a/spec/javascripts/zen_mode_spec.js +++ b/spec/javascripts/zen_mode_spec.js @@ -8,7 +8,7 @@ import ZenMode from '~/zen_mode'; var enterZen, escapeKeydown, exitZen; describe('ZenMode', function() { - var fixtureName = 'issues/open-issue.html.raw'; + var fixtureName = 'merge_requests/merge_request_with_comment.html.raw'; preloadFixtures(fixtureName); beforeEach(function() { loadFixtures(fixtureName); diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index c70a4cb55fe..1efd3113a43 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -164,9 +164,46 @@ module Ci expect(seeds.first.builds.dig(0, :name)).to eq 'spinach' end end + + context 'when kubernetes policy is specified' do + let(:pipeline) { create(:ci_empty_pipeline) } + + let(:config) do + YAML.dump( + spinach: { stage: 'test', script: 'spinach' }, + production: { + stage: 'deploy', + script: 'cap', + only: { kubernetes: 'active' } + } + ) + end + + context 'when kubernetes is active' do + let(:project) { create(:kubernetes_project) } + let(:pipeline) { create(:ci_empty_pipeline, project: project) } + + it 'returns seeds for kubernetes dependent job' do + seeds = subject.stage_seeds(pipeline) + + expect(seeds.size).to eq 2 + expect(seeds.first.builds.dig(0, :name)).to eq 'spinach' + expect(seeds.second.builds.dig(0, :name)).to eq 'production' + end + end + + context 'when kubernetes is not active' do + it 'does not return seeds for kubernetes dependent job' do + seeds = subject.stage_seeds(pipeline) + + expect(seeds.size).to eq 1 + expect(seeds.first.builds.dig(0, :name)).to eq 'spinach' + end + end + end end - describe "#builds_for_ref" do + describe "#builds_for_stage_and_ref" do let(:type) { 'test' } it "returns builds if no branch specified" do diff --git a/spec/lib/container_registry/tag_spec.rb b/spec/lib/container_registry/tag_spec.rb index e76463b5e7c..cb4ae3be525 100644 --- a/spec/lib/container_registry/tag_spec.rb +++ b/spec/lib/container_registry/tag_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe ContainerRegistry::Tag do let(:group) { create(:group, name: 'group') } - let(:project) { create(:project, :repository, path: 'test', group: group) } + let(:project) { create(:project, path: 'test', group: group) } let(:repository) do create(:container_repository, name: '', project: project) diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index 4a498e79c87..f685bb83d0d 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -279,16 +279,6 @@ describe Gitlab::Auth do gl_auth.find_with_user_password('ldap_user', 'password') end end - - context "with sign-in disabled" do - before do - stub_application_setting(password_authentication_enabled: false) - end - - it "does not find user by valid login/password" do - expect(gl_auth.find_with_user_password(username, password)).to be_nil - end - end end private diff --git a/spec/lib/gitlab/ci/config/entry/policy_spec.rb b/spec/lib/gitlab/ci/config/entry/policy_spec.rb index 36a84da4a52..5e83abf645b 100644 --- a/spec/lib/gitlab/ci/config/entry/policy_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/policy_spec.rb @@ -16,8 +16,8 @@ describe Gitlab::Ci::Config::Entry::Policy do end describe '#value' do - it 'returns key value' do - expect(entry.value).to eq config + it 'returns refs hash' do + expect(entry.value).to eq(refs: config) end end end @@ -56,6 +56,50 @@ describe Gitlab::Ci::Config::Entry::Policy do end end + context 'when using complex policy' do + context 'when specifiying refs policy' do + let(:config) { { refs: ['master'] } } + + it 'is a correct configuraton' do + expect(entry).to be_valid + expect(entry.value).to eq(refs: %w[master]) + end + end + + context 'when specifying kubernetes policy' do + let(:config) { { kubernetes: 'active' } } + + it 'is a correct configuraton' do + expect(entry).to be_valid + expect(entry.value).to eq(kubernetes: 'active') + end + end + + context 'when specifying invalid kubernetes policy' do + let(:config) { { kubernetes: 'something' } } + + it 'reports an error about invalid policy' do + expect(entry.errors).to include /unknown value: something/ + end + end + + context 'when specifying unknown policy' do + let(:config) { { refs: ['master'], invalid: :something } } + + it 'returns error about invalid key' do + expect(entry.errors).to include /unknown keys: invalid/ + end + end + + context 'when policy is empty' do + let(:config) { {} } + + it 'is not a valid configuration' do + expect(entry.errors).to include /can't be blank/ + end + end + end + context 'when policy strategy does not match' do let(:config) { 'string strategy' } diff --git a/spec/lib/gitlab/ci/stage/seed_spec.rb b/spec/lib/gitlab/ci/stage/seed_spec.rb index d7e91a5a62c..9ecd128faca 100644 --- a/spec/lib/gitlab/ci/stage/seed_spec.rb +++ b/spec/lib/gitlab/ci/stage/seed_spec.rb @@ -27,6 +27,26 @@ describe Gitlab::Ci::Stage::Seed do expect(subject.builds) .to all(include(trigger_request: pipeline.trigger_requests.first)) end + + context 'when a ref is protected' do + before do + allow_any_instance_of(Project).to receive(:protected_for?).and_return(true) + end + + it 'returns protected builds' do + expect(subject.builds).to all(include(protected: true)) + end + end + + context 'when a ref is unprotected' do + before do + allow_any_instance_of(Project).to receive(:protected_for?).and_return(false) + end + + it 'returns unprotected builds' do + expect(subject.builds).to all(include(protected: false)) + end + end end describe '#user=' do diff --git a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb index bd36d1d309d..6568a0b1bb0 100644 --- a/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_issue_handler_spec.rb @@ -13,7 +13,7 @@ describe Gitlab::Email::Handler::CreateIssueHandler do let(:email_raw) { fixture_file('emails/valid_new_issue.eml') } let(:namespace) { create(:namespace, path: 'gitlabhq') } - let!(:project) { create(:project, :public, :repository, namespace: namespace) } + let!(:project) { create(:project, :public, namespace: namespace, path: 'gitlabhq') } let!(:user) do create( :user, diff --git a/spec/lib/gitlab/email/message/repository_push_spec.rb b/spec/lib/gitlab/email/message/repository_push_spec.rb index 83c4d177cae..0ec1f931037 100644 --- a/spec/lib/gitlab/email/message/repository_push_spec.rb +++ b/spec/lib/gitlab/email/message/repository_push_spec.rb @@ -4,7 +4,7 @@ describe Gitlab::Email::Message::RepositoryPush do include RepoHelpers let!(:group) { create(:group, name: 'my_group') } - let!(:project) { create(:project, :repository, name: 'my_project', namespace: group) } + let!(:project) { create(:project, :repository, namespace: group) } let!(:author) { create(:author, name: 'Author') } let(:message) do @@ -38,7 +38,7 @@ describe Gitlab::Email::Message::RepositoryPush do describe '#project_name_with_namespace' do subject { message.project_name_with_namespace } - it { is_expected.to eq 'my_group / my_project' } + it { is_expected.to eq "#{group.name} / #{project.path}" } end describe '#author' do diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 4cfb4b7d357..08959e7bc16 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -916,27 +916,37 @@ describe Gitlab::Git::Repository, seed_helper: true do end describe '#find_branch' do - it 'should return a Branch for master' do - branch = repository.find_branch('master') + shared_examples 'finding a branch' do + it 'should return a Branch for master' do + branch = repository.find_branch('master') - expect(branch).to be_a_kind_of(Gitlab::Git::Branch) - expect(branch.name).to eq('master') - end + expect(branch).to be_a_kind_of(Gitlab::Git::Branch) + expect(branch.name).to eq('master') + end - it 'should handle non-existent branch' do - branch = repository.find_branch('this-is-garbage') + it 'should handle non-existent branch' do + branch = repository.find_branch('this-is-garbage') - expect(branch).to eq(nil) + expect(branch).to eq(nil) + end end - it 'should reload Rugged::Repository and return master' do - expect(Rugged::Repository).to receive(:new).twice.and_call_original + context 'when Gitaly find_branch feature is enabled' do + it_behaves_like 'finding a branch' + end - repository.find_branch('master') - branch = repository.find_branch('master', force_reload: true) + context 'when Gitaly find_branch feature is disabled', skip_gitaly_mock: true do + it_behaves_like 'finding a branch' - expect(branch).to be_a_kind_of(Gitlab::Git::Branch) - expect(branch.name).to eq('master') + it 'should reload Rugged::Repository and return master' do + expect(Rugged::Repository).to receive(:new).twice.and_call_original + + repository.find_branch('master') + branch = repository.find_branch('master', force_reload: true) + + expect(branch).to be_a_kind_of(Gitlab::Git::Branch) + expect(branch.name).to eq('master') + end end end diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index 295a979da76..458627ee4de 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -155,6 +155,44 @@ describe Gitlab::GitAccess do end end + shared_examples '#check with a key that is not valid' do + before do + project.add_master(user) + end + + context 'key is too small' do + before do + stub_application_setting(rsa_key_restriction: 4096) + end + + it 'does not allow keys which are too small', aggregate_failures: true do + expect(actor).not_to be_valid + expect { pull_access_check }.to raise_unauthorized('Your SSH key must be at least 4096 bits.') + expect { push_access_check }.to raise_unauthorized('Your SSH key must be at least 4096 bits.') + end + end + + context 'key type is not allowed' do + before do + stub_application_setting(rsa_key_restriction: ApplicationSetting::FORBIDDEN_KEY_VALUE) + end + + it 'does not allow keys which are too small', aggregate_failures: true do + expect(actor).not_to be_valid + expect { pull_access_check }.to raise_unauthorized(/Your SSH key type is forbidden/) + expect { push_access_check }.to raise_unauthorized(/Your SSH key type is forbidden/) + end + end + end + + it_behaves_like '#check with a key that is not valid' do + let(:actor) { build(:rsa_key_2048, user: user) } + end + + it_behaves_like '#check with a key that is not valid' do + let(:actor) { build(:rsa_deploy_key_2048, user: user) } + end + describe '#check_project_moved!' do before do project.add_master(user) diff --git a/spec/lib/gitlab/gpg/commit_spec.rb b/spec/lib/gitlab/gpg/commit_spec.rb index e521fcc6dc1..b07462e4978 100644 --- a/spec/lib/gitlab/gpg/commit_spec.rb +++ b/spec/lib/gitlab/gpg/commit_spec.rb @@ -2,45 +2,9 @@ require 'rails_helper' describe Gitlab::Gpg::Commit do describe '#signature' do - let!(:project) { create :project, :repository, path: 'sample-project' } - let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' } - - context 'unsigned commit' do - it 'returns nil' do - expect(described_class.new(project, commit_sha).signature).to be_nil - end - end - - context 'known and verified public key' do - let!(:gpg_key) do - create :gpg_key, key: GpgHelpers::User1.public_key, user: create(:user, email: GpgHelpers::User1.emails.first) - end - - before do - allow(Rugged::Commit).to receive(:extract_signature) - .with(Rugged::Repository, commit_sha) - .and_return( - [ - GpgHelpers::User1.signed_commit_signature, - GpgHelpers::User1.signed_commit_base_data - ] - ) - end - - it 'returns a valid signature' do - expect(described_class.new(project, commit_sha).signature).to have_attributes( - commit_sha: commit_sha, - project: project, - gpg_key: gpg_key, - gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - gpg_key_user_name: GpgHelpers::User1.names.first, - gpg_key_user_email: GpgHelpers::User1.emails.first, - valid_signature: true - ) - end - + shared_examples 'returns the cached signature on second call' do it 'returns the cached signature on second call' do - gpg_commit = described_class.new(project, commit_sha) + gpg_commit = described_class.new(commit) expect(gpg_commit).to receive(:using_keychain).and_call_original gpg_commit.signature @@ -51,11 +15,140 @@ describe Gitlab::Gpg::Commit do end end - context 'known but unverified public key' do - let!(:gpg_key) { create :gpg_key, key: GpgHelpers::User1.public_key } + let!(:project) { create :project, :repository, path: 'sample-project' } + let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' } - before do - allow(Rugged::Commit).to receive(:extract_signature) + context 'unsigned commit' do + let!(:commit) { create :commit, project: project, sha: commit_sha } + + it 'returns nil' do + expect(described_class.new(commit).signature).to be_nil + end + end + + context 'known key' do + context 'user matches the key uid' do + context 'user email matches the email committer' do + let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User1.emails.first } + + let!(:user) { create(:user, email: GpgHelpers::User1.emails.first) } + + let!(:gpg_key) do + create :gpg_key, key: GpgHelpers::User1.public_key, user: user + end + + before do + allow(Rugged::Commit).to receive(:extract_signature) + .with(Rugged::Repository, commit_sha) + .and_return( + [ + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data + ] + ) + end + + it 'returns a valid signature' do + expect(described_class.new(commit).signature).to have_attributes( + commit_sha: commit_sha, + project: project, + gpg_key: gpg_key, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + gpg_key_user_name: GpgHelpers::User1.names.first, + gpg_key_user_email: GpgHelpers::User1.emails.first, + verification_status: 'verified' + ) + end + + it_behaves_like 'returns the cached signature on second call' + end + + context 'user email does not match the committer email, but is the same user' do + let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User2.emails.first } + + let(:user) do + create(:user, email: GpgHelpers::User1.emails.first).tap do |user| + create :email, user: user, email: GpgHelpers::User2.emails.first + end + end + + let!(:gpg_key) do + create :gpg_key, key: GpgHelpers::User1.public_key, user: user + end + + before do + allow(Rugged::Commit).to receive(:extract_signature) + .with(Rugged::Repository, commit_sha) + .and_return( + [ + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data + ] + ) + end + + it 'returns an invalid signature' do + expect(described_class.new(commit).signature).to have_attributes( + commit_sha: commit_sha, + project: project, + gpg_key: gpg_key, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + gpg_key_user_name: GpgHelpers::User1.names.first, + gpg_key_user_email: GpgHelpers::User1.emails.first, + verification_status: 'same_user_different_email' + ) + end + + it_behaves_like 'returns the cached signature on second call' + end + + context 'user email does not match the committer email' do + let!(:commit) { create :commit, project: project, sha: commit_sha, committer_email: GpgHelpers::User2.emails.first } + + let(:user) { create(:user, email: GpgHelpers::User1.emails.first) } + + let!(:gpg_key) do + create :gpg_key, key: GpgHelpers::User1.public_key, user: user + end + + before do + allow(Rugged::Commit).to receive(:extract_signature) + .with(Rugged::Repository, commit_sha) + .and_return( + [ + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data + ] + ) + end + + it 'returns an invalid signature' do + expect(described_class.new(commit).signature).to have_attributes( + commit_sha: commit_sha, + project: project, + gpg_key: gpg_key, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + gpg_key_user_name: GpgHelpers::User1.names.first, + gpg_key_user_email: GpgHelpers::User1.emails.first, + verification_status: 'other_user' + ) + end + + it_behaves_like 'returns the cached signature on second call' + end + end + + context 'user does not match the key uid' do + let!(:commit) { create :commit, project: project, sha: commit_sha } + + let(:user) { create(:user, email: GpgHelpers::User2.emails.first) } + + let!(:gpg_key) do + create :gpg_key, key: GpgHelpers::User1.public_key, user: user + end + + before do + allow(Rugged::Commit).to receive(:extract_signature) .with(Rugged::Repository, commit_sha) .and_return( [ @@ -63,33 +156,27 @@ describe Gitlab::Gpg::Commit do GpgHelpers::User1.signed_commit_base_data ] ) - end - - it 'returns an invalid signature' do - expect(described_class.new(project, commit_sha).signature).to have_attributes( - commit_sha: commit_sha, - project: project, - gpg_key: gpg_key, - gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - gpg_key_user_name: GpgHelpers::User1.names.first, - gpg_key_user_email: GpgHelpers::User1.emails.first, - valid_signature: false - ) - end - - it 'returns the cached signature on second call' do - gpg_commit = described_class.new(project, commit_sha) - - expect(gpg_commit).to receive(:using_keychain).and_call_original - gpg_commit.signature + end + + it 'returns an invalid signature' do + expect(described_class.new(commit).signature).to have_attributes( + commit_sha: commit_sha, + project: project, + gpg_key: gpg_key, + gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, + gpg_key_user_name: GpgHelpers::User1.names.first, + gpg_key_user_email: GpgHelpers::User1.emails.first, + verification_status: 'unverified_key' + ) + end - # consecutive call - expect(gpg_commit).not_to receive(:using_keychain).and_call_original - gpg_commit.signature + it_behaves_like 'returns the cached signature on second call' end end - context 'unknown public key' do + context 'unknown key' do + let!(:commit) { create :commit, project: project, sha: commit_sha } + before do allow(Rugged::Commit).to receive(:extract_signature) .with(Rugged::Repository, commit_sha) @@ -102,27 +189,18 @@ describe Gitlab::Gpg::Commit do end it 'returns an invalid signature' do - expect(described_class.new(project, commit_sha).signature).to have_attributes( + expect(described_class.new(commit).signature).to have_attributes( commit_sha: commit_sha, project: project, gpg_key: nil, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, gpg_key_user_name: nil, gpg_key_user_email: nil, - valid_signature: false + verification_status: 'unknown_key' ) end - it 'returns the cached signature on second call' do - gpg_commit = described_class.new(project, commit_sha) - - expect(gpg_commit).to receive(:using_keychain).and_call_original - gpg_commit.signature - - # consecutive call - expect(gpg_commit).not_to receive(:using_keychain).and_call_original - gpg_commit.signature - end + it_behaves_like 'returns the cached signature on second call' end end end diff --git a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb index 4de4419de27..b9fd4d02156 100644 --- a/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb +++ b/spec/lib/gitlab/gpg/invalid_gpg_signature_updater_spec.rb @@ -4,8 +4,29 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do describe '#run' do let!(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' } let!(:project) { create :project, :repository, path: 'sample-project' } + let!(:raw_commit) do + raw_commit = double( + :raw_commit, + signature: [ + GpgHelpers::User1.signed_commit_signature, + GpgHelpers::User1.signed_commit_base_data + ], + sha: commit_sha, + committer_email: GpgHelpers::User1.emails.first + ) + + allow(raw_commit).to receive :save! + + raw_commit + end + + let!(:commit) do + create :commit, git_commit: raw_commit, project: project + end before do + allow_any_instance_of(Project).to receive(:commit).and_return(commit) + allow(Rugged::Commit).to receive(:extract_signature) .with(Rugged::Repository, commit_sha) .and_return( @@ -25,7 +46,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do commit_sha: commit_sha, gpg_key: nil, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - valid_signature: true + verification_status: 'verified' end it 'assigns the gpg key to the signature when the missing gpg key is added' do @@ -39,7 +60,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do commit_sha: commit_sha, gpg_key: gpg_key, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - valid_signature: true + verification_status: 'verified' ) end @@ -54,7 +75,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do commit_sha: commit_sha, gpg_key: nil, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - valid_signature: true + verification_status: 'verified' ) end end @@ -68,7 +89,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do commit_sha: commit_sha, gpg_key: nil, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - valid_signature: false + verification_status: 'unknown_key' end it 'updates the signature to being valid when the missing gpg key is added' do @@ -82,7 +103,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do commit_sha: commit_sha, gpg_key: gpg_key, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - valid_signature: true + verification_status: 'verified' ) end @@ -97,7 +118,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do commit_sha: commit_sha, gpg_key: nil, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - valid_signature: false + verification_status: 'unknown_key' ) end end @@ -115,7 +136,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do commit_sha: commit_sha, gpg_key: nil, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - valid_signature: false + verification_status: 'unknown_key' end it 'updates the signature to being valid when the user updates the email address' do @@ -123,7 +144,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do key: GpgHelpers::User1.public_key, user: user - expect(invalid_gpg_signature.reload.valid_signature).to be_falsey + expect(invalid_gpg_signature.reload.verification_status).to eq 'unverified_key' # InvalidGpgSignatureUpdater is called by the after_update hook user.update_attributes!(email: GpgHelpers::User1.emails.first) @@ -133,7 +154,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do commit_sha: commit_sha, gpg_key: gpg_key, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - valid_signature: true + verification_status: 'verified' ) end @@ -147,7 +168,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do commit_sha: commit_sha, gpg_key: gpg_key, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - valid_signature: false + verification_status: 'unverified_key' ) # InvalidGpgSignatureUpdater is called by the after_update hook @@ -158,7 +179,7 @@ RSpec.describe Gitlab::Gpg::InvalidGpgSignatureUpdater do commit_sha: commit_sha, gpg_key: gpg_key, gpg_key_primary_keyid: GpgHelpers::User1.primary_keyid, - valid_signature: false + verification_status: 'unverified_key' ) end end diff --git a/spec/lib/gitlab/gpg_spec.rb b/spec/lib/gitlab/gpg_spec.rb index 30ad033b204..11a2aea1915 100644 --- a/spec/lib/gitlab/gpg_spec.rb +++ b/spec/lib/gitlab/gpg_spec.rb @@ -42,6 +42,21 @@ describe Gitlab::Gpg do described_class.user_infos_from_key('bogus') ).to eq [] end + + it 'downcases the email' do + public_key = double(:key) + fingerprints = double(:fingerprints) + uid = double(:uid, name: 'Nannie Bernhard', email: 'NANNIE.BERNHARD@EXAMPLE.COM') + raw_key = double(:raw_key, uids: [uid]) + allow(Gitlab::Gpg::CurrentKeyChain).to receive(:fingerprints_from_key).with(public_key).and_return(fingerprints) + allow(GPGME::Key).to receive(:find).with(:public, anything).and_return([raw_key]) + + user_infos = described_class.user_infos_from_key(public_key) + expect(user_infos).to eq([{ + name: 'Nannie Bernhard', + email: 'nannie.bernhard@example.com' + }]) + end end describe '.current_home_dir' do diff --git a/spec/lib/gitlab/i18n/metadata_entry_spec.rb b/spec/lib/gitlab/i18n/metadata_entry_spec.rb new file mode 100644 index 00000000000..ab71d6454a9 --- /dev/null +++ b/spec/lib/gitlab/i18n/metadata_entry_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe Gitlab::I18n::MetadataEntry do + describe '#expected_plurals' do + it 'returns the number of plurals' do + data = { + msgid: "", + msgstr: [ + "", + "Project-Id-Version: gitlab 1.0.0\\n", + "Report-Msgid-Bugs-To: \\n", + "PO-Revision-Date: 2017-07-13 12:10-0500\\n", + "Language-Team: Spanish\\n", + "Language: es\\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", + "Last-Translator: Bob Van Landuyt <bob@gitlab.com>\\n", + "X-Generator: Poedit 2.0.2\\n" + ] + } + entry = described_class.new(data) + + expect(entry.expected_plurals).to eq(2) + end + + it 'returns 0 for the POT-metadata' do + data = { + msgid: "", + msgstr: [ + "", + "Project-Id-Version: gitlab 1.0.0\\n", + "Report-Msgid-Bugs-To: \\n", + "PO-Revision-Date: 2017-07-13 12:10-0500\\n", + "Language-Team: Spanish\\n", + "Language: es\\n", + "MIME-Version: 1.0\\n", + "Content-Type: text/plain; charset=UTF-8\\n", + "Content-Transfer-Encoding: 8bit\\n", + "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n", + "Last-Translator: Bob Van Landuyt <bob@gitlab.com>\\n", + "X-Generator: Poedit 2.0.2\\n" + ] + } + entry = described_class.new(data) + + expect(entry.expected_plurals).to eq(0) + end + end +end diff --git a/spec/lib/gitlab/i18n/po_linter_spec.rb b/spec/lib/gitlab/i18n/po_linter_spec.rb new file mode 100644 index 00000000000..3a962ba7f22 --- /dev/null +++ b/spec/lib/gitlab/i18n/po_linter_spec.rb @@ -0,0 +1,338 @@ +require 'spec_helper' +require 'simple_po_parser' + +describe Gitlab::I18n::PoLinter do + let(:linter) { described_class.new(po_path) } + let(:po_path) { 'spec/fixtures/valid.po' } + + describe '#errors' do + it 'only calls validation once' do + expect(linter).to receive(:validate_po).once.and_call_original + + 2.times { linter.errors } + end + end + + describe '#validate_po' do + subject(:errors) { linter.validate_po } + + context 'for a fuzzy message' do + let(:po_path) { 'spec/fixtures/fuzzy.po' } + + it 'has an error' do + is_expected.to include('PipelineSchedules|Remove variable row' => ['is marked fuzzy']) + end + end + + context 'for a translations with newlines' do + let(:po_path) { 'spec/fixtures/newlines.po' } + + it 'has an error for a normal string' do + message_id = "You are going to remove %{group_name}.\\nRemoved groups CANNOT be restored!\\nAre you ABSOLUTELY sure?" + expected_message = "is defined over multiple lines, this breaks some tooling." + + expect(errors[message_id]).to include(expected_message) + end + + it 'has an error when a translation is defined over multiple lines' do + message_id = "You are going to remove %{group_name}.\\nRemoved groups CANNOT be restored!\\nAre you ABSOLUTELY sure?" + expected_message = "has translations defined over multiple lines, this breaks some tooling." + + expect(errors[message_id]).to include(expected_message) + end + + it 'raises an error when a plural translation is defined over multiple lines' do + message_id = 'With plural' + expected_message = "has translations defined over multiple lines, this breaks some tooling." + + expect(errors[message_id]).to include(expected_message) + end + + it 'raises an error when the plural id is defined over multiple lines' do + message_id = 'multiline plural id' + expected_message = "plural is defined over multiple lines, this breaks some tooling." + + expect(errors[message_id]).to include(expected_message) + end + end + + context 'with an invalid po' do + let(:po_path) { 'spec/fixtures/invalid.po' } + + it 'returns the error' do + is_expected.to include('PO-syntax errors' => a_kind_of(Array)) + end + + it 'does not validate entries' do + expect(linter).not_to receive(:validate_entries) + + linter.validate_po + end + end + + context 'with missing metadata' do + let(:po_path) { 'spec/fixtures/missing_metadata.po' } + + it 'returns the an error' do + is_expected.to include('PO-syntax errors' => a_kind_of(Array)) + end + end + + context 'with a valid po' do + it 'parses the file' do + expect(linter).to receive(:parse_po).and_call_original + + linter.validate_po + end + + it 'validates the entries' do + expect(linter).to receive(:validate_entries).and_call_original + + linter.validate_po + end + + it 'has no errors' do + is_expected.to be_empty + end + end + + context 'with missing plurals' do + let(:po_path) { 'spec/fixtures/missing_plurals.po' } + + it 'has errors' do + is_expected.not_to be_empty + end + end + + context 'with multiple plurals' do + let(:po_path) { 'spec/fixtures/multiple_plurals.po' } + + it 'has errors' do + is_expected.not_to be_empty + end + end + + context 'with unescaped chars' do + let(:po_path) { 'spec/fixtures/unescaped_chars.po' } + + it 'contains an error' do + message_id = 'You are going to transfer %{project_name_with_namespace} to another owner. Are you ABSOLUTELY sure?' + expected_error = 'translation contains unescaped `%`, escape it using `%%`' + + expect(errors[message_id]).to include(expected_error) + end + end + end + + describe '#parse_po' do + context 'with a valid po' do + it 'fills in the entries' do + linter.parse_po + + expect(linter.translation_entries).not_to be_empty + expect(linter.metadata_entry).to be_kind_of(Gitlab::I18n::MetadataEntry) + end + + it 'does not have errors' do + expect(linter.parse_po).to be_nil + end + end + + context 'with an invalid po' do + let(:po_path) { 'spec/fixtures/invalid.po' } + + it 'contains an error' do + expect(linter.parse_po).not_to be_nil + end + + it 'sets the entries to an empty array' do + linter.parse_po + + expect(linter.translation_entries).to eq([]) + end + end + end + + describe '#validate_entries' do + it 'keeps track of errors for entries' do + fake_invalid_entry = Gitlab::I18n::TranslationEntry.new( + { msgid: "Hello %{world}", msgstr: "Bonjour %{monde}" }, 2 + ) + allow(linter).to receive(:translation_entries) { [fake_invalid_entry] } + + expect(linter).to receive(:validate_entry) + .with(fake_invalid_entry) + .and_call_original + + expect(linter.validate_entries).to include("Hello %{world}" => an_instance_of(Array)) + end + end + + describe '#validate_entry' do + it 'validates the flags, variable usage, newlines, and unescaped chars' do + fake_entry = double + + expect(linter).to receive(:validate_flags).with([], fake_entry) + expect(linter).to receive(:validate_variables).with([], fake_entry) + expect(linter).to receive(:validate_newlines).with([], fake_entry) + expect(linter).to receive(:validate_number_of_plurals).with([], fake_entry) + expect(linter).to receive(:validate_unescaped_chars).with([], fake_entry) + + linter.validate_entry(fake_entry) + end + end + + describe '#validate_number_of_plurals' do + it 'validates when there are an incorrect number of translations' do + fake_metadata = double + allow(fake_metadata).to receive(:expected_plurals).and_return(2) + allow(linter).to receive(:metadata_entry).and_return(fake_metadata) + + fake_entry = Gitlab::I18n::TranslationEntry.new( + { msgid: 'the singular', msgid_plural: 'the plural', 'msgstr[0]' => 'the singular' }, + 2 + ) + errors = [] + + linter.validate_number_of_plurals(errors, fake_entry) + + expect(errors).to include('should have 2 translations') + end + end + + describe '#validate_variables' do + it 'validates both signular and plural in a pluralized string when the entry has a singular' do + pluralized_entry = Gitlab::I18n::TranslationEntry.new( + { msgid: 'Hello %{world}', + msgid_plural: 'Hello all %{world}', + 'msgstr[0]' => 'Bonjour %{world}', + 'msgstr[1]' => 'Bonjour tous %{world}' }, + 2 + ) + + expect(linter).to receive(:validate_variables_in_message) + .with([], 'Hello %{world}', 'Bonjour %{world}') + .and_call_original + expect(linter).to receive(:validate_variables_in_message) + .with([], 'Hello all %{world}', 'Bonjour tous %{world}') + .and_call_original + + linter.validate_variables([], pluralized_entry) + end + + it 'only validates plural when there is no separate singular' do + pluralized_entry = Gitlab::I18n::TranslationEntry.new( + { msgid: 'Hello %{world}', + msgid_plural: 'Hello all %{world}', + 'msgstr[0]' => 'Bonjour %{world}' }, + 1 + ) + + expect(linter).to receive(:validate_variables_in_message) + .with([], 'Hello all %{world}', 'Bonjour %{world}') + + linter.validate_variables([], pluralized_entry) + end + + it 'validates the message variables' do + entry = Gitlab::I18n::TranslationEntry.new( + { msgid: 'Hello', msgstr: 'Bonjour' }, + 2 + ) + + expect(linter).to receive(:validate_variables_in_message) + .with([], 'Hello', 'Bonjour') + + linter.validate_variables([], entry) + end + end + + describe '#validate_variables_in_message' do + it 'detects when a variables are used incorrectly' do + errors = [] + + expected_errors = ['<hello %{world} %d> is missing: [%{hello}]', + '<hello %{world} %d> is using unknown variables: [%{world}]', + 'is combining multiple unnamed variables'] + + linter.validate_variables_in_message(errors, '%{hello} world %d', 'hello %{world} %d') + + expect(errors).to include(*expected_errors) + end + end + + describe '#validate_translation' do + it 'succeeds with valid variables' do + errors = [] + + linter.validate_translation(errors, 'Hello %{world}', ['%{world}']) + + expect(errors).to be_empty + end + + it 'adds an error message when translating fails' do + errors = [] + + expect(FastGettext::Translation).to receive(:_) { raise 'broken' } + + linter.validate_translation(errors, 'Hello', []) + + expect(errors).to include('Failure translating to en with []: broken') + end + + it 'adds an error message when translating fails when translating with context' do + errors = [] + + expect(FastGettext::Translation).to receive(:s_) { raise 'broken' } + + linter.validate_translation(errors, 'Tests|Hello', []) + + expect(errors).to include('Failure translating to en with []: broken') + end + + it "adds an error when trying to translate with incorrect variables when using unnamed variables" do + errors = [] + + linter.validate_translation(errors, 'Hello %d', ['%s']) + + expect(errors.first).to start_with("Failure translating to en with") + end + + it "adds an error when trying to translate with named variables when unnamed variables are expected" do + errors = [] + + linter.validate_translation(errors, 'Hello %d', ['%{world}']) + + expect(errors.first).to start_with("Failure translating to en with") + end + + it 'adds an error when translated with incorrect variables using named variables' do + errors = [] + + linter.validate_translation(errors, 'Hello %{thing}', ['%d']) + + expect(errors.first).to start_with("Failure translating to en with") + end + end + + describe '#fill_in_variables' do + it 'builds an array for %d translations' do + result = linter.fill_in_variables(['%d']) + + expect(result).to contain_exactly(a_kind_of(Integer)) + end + + it 'builds an array for %s translations' do + result = linter.fill_in_variables(['%s']) + + expect(result).to contain_exactly(a_kind_of(String)) + end + + it 'builds a hash for named variables' do + result = linter.fill_in_variables(['%{hello}']) + + expect(result).to be_a(Hash) + expect(result).to include('hello' => an_instance_of(String)) + end + end +end diff --git a/spec/lib/gitlab/i18n/translation_entry_spec.rb b/spec/lib/gitlab/i18n/translation_entry_spec.rb new file mode 100644 index 00000000000..f68bc8feff9 --- /dev/null +++ b/spec/lib/gitlab/i18n/translation_entry_spec.rb @@ -0,0 +1,203 @@ +require 'spec_helper' + +describe Gitlab::I18n::TranslationEntry do + describe '#singular_translation' do + it 'returns the normal `msgstr` for translations without plural' do + data = { msgid: 'Hello world', msgstr: 'Bonjour monde' } + entry = described_class.new(data, 2) + + expect(entry.singular_translation).to eq('Bonjour monde') + end + + it 'returns the first string for entries with plurals' do + data = { + msgid: 'Hello world', + msgid_plural: 'Hello worlds', + 'msgstr[0]' => 'Bonjour monde', + 'msgstr[1]' => 'Bonjour mondes' + } + entry = described_class.new(data, 2) + + expect(entry.singular_translation).to eq('Bonjour monde') + end + end + + describe '#all_translations' do + it 'returns all translations for singular translations' do + data = { msgid: 'Hello world', msgstr: 'Bonjour monde' } + entry = described_class.new(data, 2) + + expect(entry.all_translations).to eq(['Bonjour monde']) + end + + it 'returns all translations when including plural translations' do + data = { + msgid: 'Hello world', + msgid_plural: 'Hello worlds', + 'msgstr[0]' => 'Bonjour monde', + 'msgstr[1]' => 'Bonjour mondes' + } + entry = described_class.new(data, 2) + + expect(entry.all_translations).to eq(['Bonjour monde', 'Bonjour mondes']) + end + end + + describe '#plural_translations' do + it 'returns all translations if there is only one plural' do + data = { + msgid: 'Hello world', + msgid_plural: 'Hello worlds', + 'msgstr[0]' => 'Bonjour monde' + } + entry = described_class.new(data, 1) + + expect(entry.plural_translations).to eq(['Bonjour monde']) + end + + it 'returns all translations except for the first one if there are multiple' do + data = { + msgid: 'Hello world', + msgid_plural: 'Hello worlds', + 'msgstr[0]' => 'Bonjour monde', + 'msgstr[1]' => 'Bonjour mondes', + 'msgstr[2]' => 'Bonjour tous les mondes' + } + entry = described_class.new(data, 3) + + expect(entry.plural_translations).to eq(['Bonjour mondes', 'Bonjour tous les mondes']) + end + end + + describe '#has_singular_translation?' do + it 'has a singular when the translation is not pluralized' do + data = { + msgid: 'hello world', + msgstr: 'hello' + } + entry = described_class.new(data, 2) + + expect(entry).to have_singular_translation + end + + it 'has a singular when plural and singular are separately defined' do + data = { + msgid: 'hello world', + msgid_plural: 'hello worlds', + "msgstr[0]" => 'hello world', + "msgstr[1]" => 'hello worlds' + } + entry = described_class.new(data, 2) + + expect(entry).to have_singular_translation + end + + it 'does not have a separate singular if the plural string only has one translation' do + data = { + msgid: 'hello world', + msgid_plural: 'hello worlds', + "msgstr[0]" => 'hello worlds' + } + entry = described_class.new(data, 1) + + expect(entry).not_to have_singular_translation + end + end + + describe '#msgid_contains_newlines' do + it 'is true when the msgid is an array' do + data = { msgid: %w(hello world) } + entry = described_class.new(data, 2) + + expect(entry.msgid_contains_newlines?).to be_truthy + end + end + + describe '#plural_id_contains_newlines' do + it 'is true when the msgid is an array' do + data = { msgid_plural: %w(hello world) } + entry = described_class.new(data, 2) + + expect(entry.plural_id_contains_newlines?).to be_truthy + end + end + + describe '#translations_contain_newlines' do + it 'is true when the msgid is an array' do + data = { msgstr: %w(hello world) } + entry = described_class.new(data, 2) + + expect(entry.translations_contain_newlines?).to be_truthy + end + end + + describe '#contains_unescaped_chars' do + let(:data) { { msgid: '' } } + let(:entry) { described_class.new(data, 2) } + it 'is true when the msgid is an array' do + string = '「100%確定」' + + expect(entry.contains_unescaped_chars?(string)).to be_truthy + end + + it 'is false when the `%` char is escaped' do + string = '「100%%確定」' + + expect(entry.contains_unescaped_chars?(string)).to be_falsy + end + + it 'is false when using an unnamed variable' do + string = '「100%d確定」' + + expect(entry.contains_unescaped_chars?(string)).to be_falsy + end + + it 'is false when using a named variable' do + string = '「100%{named}確定」' + + expect(entry.contains_unescaped_chars?(string)).to be_falsy + end + + it 'is true when an unnamed variable is not closed' do + string = '「100%{named確定」' + + expect(entry.contains_unescaped_chars?(string)).to be_truthy + end + + it 'is true when the string starts with a `%`' do + string = '%10' + + expect(entry.contains_unescaped_chars?(string)).to be_truthy + end + end + + describe '#msgid_contains_unescaped_chars' do + it 'is true when the msgid contains a `%`' do + data = { msgid: '「100%確定」' } + entry = described_class.new(data, 2) + + expect(entry).to receive(:contains_unescaped_chars?).and_call_original + expect(entry.msgid_contains_unescaped_chars?).to be_truthy + end + end + + describe '#plural_id_contains_unescaped_chars' do + it 'is true when the plural msgid contains a `%`' do + data = { msgid_plural: '「100%確定」' } + entry = described_class.new(data, 2) + + expect(entry).to receive(:contains_unescaped_chars?).and_call_original + expect(entry.plural_id_contains_unescaped_chars?).to be_truthy + end + end + + describe '#translations_contain_unescaped_chars' do + it 'is true when the translation contains a `%`' do + data = { msgstr: '「100%確定」' } + entry = described_class.new(data, 2) + + expect(entry).to receive(:contains_unescaped_chars?).and_call_original + expect(entry.translations_contain_unescaped_chars?).to be_truthy + end + end +end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index a5e03e149a7..b852ac570a3 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -224,6 +224,7 @@ Ci::Pipeline: - lock_version - auto_canceled_by_id - pipeline_schedule_id +- protected Ci::Stage: - id - name @@ -276,6 +277,8 @@ CommitStatus: - coverage_regex - auto_canceled_by_id - retried +- protected +- failure_reason Ci::Variable: - id - project_id diff --git a/spec/lib/gitlab/issuables_count_for_state_spec.rb b/spec/lib/gitlab/issuables_count_for_state_spec.rb new file mode 100644 index 00000000000..c262fdfcb61 --- /dev/null +++ b/spec/lib/gitlab/issuables_count_for_state_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe Gitlab::IssuablesCountForState do + let(:finder) do + double(:finder, count_by_state: { opened: 2, closed: 1 }) + end + + let(:counter) { described_class.new(finder) } + + describe '#for_state_or_opened' do + it 'returns the number of issuables for the given state' do + expect(counter.for_state_or_opened(:closed)).to eq(1) + end + + it 'returns the number of open issuables when no state is given' do + expect(counter.for_state_or_opened).to eq(2) + end + + it 'returns the number of open issuables when a nil value is given' do + expect(counter.for_state_or_opened(nil)).to eq(2) + end + end + + describe '#[]' do + it 'returns the number of issuables for the given state' do + expect(counter[:closed]).to eq(1) + end + + it 'casts valid states from Strings to Symbols' do + expect(counter['closed']).to eq(1) + end + + it 'returns 0 when using an invalid state name as a String' do + expect(counter['kittens']).to be_zero + end + end +end diff --git a/spec/lib/gitlab/key_fingerprint_spec.rb b/spec/lib/gitlab/key_fingerprint_spec.rb deleted file mode 100644 index d643dc5342d..00000000000 --- a/spec/lib/gitlab/key_fingerprint_spec.rb +++ /dev/null @@ -1,82 +0,0 @@ -require 'spec_helper' - -describe Gitlab::KeyFingerprint, lib: true do - KEYS = { - rsa: - 'example.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC5z65PwQ1GE6foJgwk' \ - '9rmQi/glaXbUeVa5uvQpnZ3Z5+forcI7aTngh3aZ/H2UDP2L70TGy7kKNyp0J3a8/OdG' \ - 'Z08y5yi3JlbjFARO1NyoFEjw2H1SJxeJ43L6zmvTlu+hlK1jSAlidl7enS0ufTlzEEj4' \ - 'iJcuTPKdVzKRgZuTRVm9woWNVKqIrdRC0rJiTinERnfSAp/vNYERMuaoN4oJt8p/NEek' \ - 'rmFoDsQOsyDW5RAnCnjWUU+jFBKDpfkJQ1U2n6BjJewC9dl6ODK639l3yN4WOLZEk4tN' \ - 'UysfbGeF3rmMeflaD6O1Jplpv3YhwVGFNKa7fMq6k3Z0tszTJPYh', - ecdsa: - 'example.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAI' \ - 'bmlzdHAyNTYAAABBBKTJy43NZzJSfNxpv/e2E6Zy3qoHoTQbmOsU5FEfpWfWa1MdTeXQ' \ - 'YvKOi+qz/1AaNx6BK421jGu74JCDJtiZWT8=', - ed25519: - '@revoked example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjq' \ - 'uxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf', - dss: - 'example.com ssh-dss AAAAB3NzaC1kc3MAAACBAP1/U4EddRIpUt9KnC7s5Of2EbdS' \ - 'PO9EAMMeP4C2USZpRV1AIlH7WT2NWPq/xfW6MPbLm1Vs14E7gB00b/JmYLdrmVClpJ+f' \ - '6AR7ECLCT7up1/63xhv4O1fnxqimFQ8E+4P208UewwI1VBNaFpEy9nXzrith1yrv8iID' \ - 'GZ3RSAHHAAAAFQCXYFCPFSMLzLKSuYKi64QL8Fgc9QAAAIEA9+GghdabPd7LvKtcNrhX' \ - 'uXmUr7v6OuqC+VdMCz0HgmdRWVeOutRZT+ZxBxCBgLRJFnEj6EwoFhO3zwkyjMim4TwW' \ - 'eotUfI0o4KOuHiuzpnWRbqN/C/ohNWLx+2J6ASQ7zKTxvqhRkImog9/hWuWfBpKLZl6A' \ - 'e1UlZAFMO/7PSSoAAACBAJcQ4JODqhuGbXIEpqxetm7PWbdbCcr3y/GzIZ066pRovpL6' \ - 'qm3qCVIym4cyChxWwb8qlyCIi+YRUUWm1z/wiBYT2Vf3S4FXBnyymCkKEaV/EY7+jd4X' \ - '1bXI58OD2u+bLCB/sInM4fGB8CZUIWT9nJH0Ve9jJUge2ms348/QOJ1+' - }.freeze - - MD5_FINGERPRINTS = { - rsa: '06:b2:8a:92:df:0e:11:2c:ca:7b:8f:a4:ba:6e:4b:fd', - ecdsa: '45:ff:5b:98:9a:b6:8a:41:13:c1:30:8b:09:5e:7b:4e', - ed25519: '2e:65:6a:c8:cf:bf:b2:8b:9a:bd:6d:9f:11:5c:12:16', - dss: '57:98:86:02:5f:9c:f4:9b:ad:5a:1e:51:92:0e:fd:2b' - }.freeze - - BIT_COUNTS = { - rsa: 2048, - ecdsa: 256, - ed25519: 256, - dss: 1024 - }.freeze - - describe '#type' do - KEYS.each do |type, key| - it "calculates the type of #{type} keys" do - calculated_type = described_class.new(key).type - - expect(calculated_type).to eq(type.to_s.upcase) - end - end - end - - describe '#fingerprint' do - KEYS.each do |type, key| - it "calculates the MD5 fingerprint for #{type} keys" do - fp = described_class.new(key).fingerprint - - expect(fp).to eq(MD5_FINGERPRINTS[type]) - end - end - end - - describe '#bits' do - KEYS.each do |type, key| - it "calculates the number of bits in #{type} keys" do - bits = described_class.new(key).bits - - expect(bits).to eq(BIT_COUNTS[type]) - end - end - end - - describe '#key' do - it 'carries the unmodified key data' do - key = described_class.new(KEYS[:rsa]).key - - expect(key).to eq(KEYS[:rsa]) - end - end -end diff --git a/spec/lib/gitlab/reference_counter_spec.rb b/spec/lib/gitlab/reference_counter_spec.rb new file mode 100644 index 00000000000..b2344d1870a --- /dev/null +++ b/spec/lib/gitlab/reference_counter_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe Gitlab::ReferenceCounter do + let(:redis) { double('redis') } + let(:reference_counter_key) { "git-receive-pack-reference-counter:project-1" } + let(:reference_counter) { described_class.new('project-1') } + + before do + allow(Gitlab::Redis::SharedState).to receive(:with).and_yield(redis) + end + + it 'increases and set the expire time of a reference count for a path' do + expect(redis).to receive(:incr).with(reference_counter_key) + expect(redis).to receive(:expire).with(reference_counter_key, + described_class::REFERENCE_EXPIRE_TIME) + expect(reference_counter.increase).to be(true) + end + + it 'decreases the reference count for a path' do + allow(redis).to receive(:decr).and_return(0) + expect(redis).to receive(:decr).with(reference_counter_key) + expect(reference_counter.decrease).to be(true) + end + + it 'warns if attempting to decrease a counter with a value of one or less, and resets the counter' do + expect(redis).to receive(:decr).and_return(-1) + expect(redis).to receive(:del) + expect(Rails.logger).to receive(:warn).with("Reference counter for project-1" \ + " decreased when its value was less than 1. Reseting the counter.") + expect(reference_counter.decrease).to be(true) + end + + it 'get the reference count for a path' do + allow(redis).to receive(:get).and_return(1) + expect(reference_counter.value).to be(1) + end +end diff --git a/spec/lib/gitlab/sentry_spec.rb b/spec/lib/gitlab/sentry_spec.rb new file mode 100644 index 00000000000..8c211d1c63f --- /dev/null +++ b/spec/lib/gitlab/sentry_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe Gitlab::Sentry do + describe '.context' do + it 'adds the locale to the tags' do + expect(described_class).to receive(:enabled?).and_return(true) + + described_class.context(nil) + + expect(Raven.tags_context[:locale]).to eq(I18n.locale.to_s) + end + end +end diff --git a/spec/lib/gitlab/sql/pattern_spec.rb b/spec/lib/gitlab/sql/pattern_spec.rb index 9d7b2136dab..48d56628ed5 100644 --- a/spec/lib/gitlab/sql/pattern_spec.rb +++ b/spec/lib/gitlab/sql/pattern_spec.rb @@ -52,4 +52,124 @@ describe Gitlab::SQL::Pattern do end end end + + describe '.select_fuzzy_words' do + subject(:select_fuzzy_words) { Issue.select_fuzzy_words(query) } + + context 'with a word equal to 3 chars' do + let(:query) { 'foo' } + + it 'returns array cotaining a word' do + expect(select_fuzzy_words).to match_array(['foo']) + end + end + + context 'with a word shorter than 3 chars' do + let(:query) { 'fo' } + + it 'returns empty array' do + expect(select_fuzzy_words).to match_array([]) + end + end + + context 'with two words both equal to 3 chars' do + let(:query) { 'foo baz' } + + it 'returns array containing two words' do + expect(select_fuzzy_words).to match_array(%w[foo baz]) + end + end + + context 'with two words divided by two spaces both equal to 3 chars' do + let(:query) { 'foo baz' } + + it 'returns array containing two words' do + expect(select_fuzzy_words).to match_array(%w[foo baz]) + end + end + + context 'with two words equal to 3 chars and shorter than 3 chars' do + let(:query) { 'foo ba' } + + it 'returns array containing a word' do + expect(select_fuzzy_words).to match_array(['foo']) + end + end + + context 'with a multi-word surrounded by double quote' do + let(:query) { '"really bar"' } + + it 'returns array containing a multi-word' do + expect(select_fuzzy_words).to match_array(['really bar']) + end + end + + context 'with a multi-word surrounded by double quote and two words' do + let(:query) { 'foo "really bar" baz' } + + it 'returns array containing a multi-word and tow words' do + expect(select_fuzzy_words).to match_array(['foo', 'really bar', 'baz']) + end + end + + context 'with a multi-word surrounded by double quote missing a spece before the first double quote' do + let(:query) { 'foo"really bar"' } + + it 'returns array containing two words with double quote' do + expect(select_fuzzy_words).to match_array(['foo"really', 'bar"']) + end + end + + context 'with a multi-word surrounded by double quote missing a spece after the second double quote' do + let(:query) { '"really bar"baz' } + + it 'returns array containing two words with double quote' do + expect(select_fuzzy_words).to match_array(['"really', 'bar"baz']) + end + end + + context 'with two multi-word surrounded by double quote and two words' do + let(:query) { 'foo "really bar" baz "awesome feature"' } + + it 'returns array containing two multi-words and tow words' do + expect(select_fuzzy_words).to match_array(['foo', 'really bar', 'baz', 'awesome feature']) + end + end + end + + describe '.to_fuzzy_arel' do + subject(:to_fuzzy_arel) { Issue.to_fuzzy_arel(:title, query) } + + context 'with a word equal to 3 chars' do + let(:query) { 'foo' } + + it 'returns a single ILIKE condition' do + expect(to_fuzzy_arel.to_sql).to match(/title.*I?LIKE '\%foo\%'/) + end + end + + context 'with a word shorter than 3 chars' do + let(:query) { 'fo' } + + it 'returns nil' do + expect(to_fuzzy_arel).to be_nil + end + end + + context 'with two words both equal to 3 chars' do + let(:query) { 'foo baz' } + + it 'returns a joining LIKE condition using a AND' do + expect(to_fuzzy_arel.to_sql).to match(/title.+I?LIKE '\%foo\%' AND .*title.*I?LIKE '\%baz\%'/) + end + end + + context 'with a multi-word surrounded by double quote and two words' do + let(:query) { 'foo "really bar" baz' } + + it 'returns a joining LIKE condition using a AND' do + expect(to_fuzzy_arel.to_sql).to match(/title.+I?LIKE '\%foo\%' AND .*title.*I?LIKE '\%baz\%' AND .*title.*I?LIKE '\%really bar\%'/) + end + end + end end diff --git a/spec/lib/gitlab/ssh_public_key_spec.rb b/spec/lib/gitlab/ssh_public_key_spec.rb new file mode 100644 index 00000000000..93d538141ce --- /dev/null +++ b/spec/lib/gitlab/ssh_public_key_spec.rb @@ -0,0 +1,136 @@ +require 'spec_helper' + +describe Gitlab::SSHPublicKey, lib: true do + let(:key) { attributes_for(:rsa_key_2048)[:key] } + let(:public_key) { described_class.new(key) } + + describe '.technology(name)' do + it 'returns nil for an unrecognised name' do + expect(described_class.technology(:foo)).to be_nil + end + + where(:name) do + [:rsa, :dsa, :ecdsa, :ed25519] + end + + with_them do + it { expect(described_class.technology(name).name).to eq(name) } + it { expect(described_class.technology(name.to_s).name).to eq(name) } + end + end + + describe '.supported_sizes(name)' do + where(:name, :sizes) do + [ + [:rsa, [1024, 2048, 3072, 4096]], + [:dsa, [1024, 2048, 3072]], + [:ecdsa, [256, 384, 521]], + [:ed25519, [256]] + ] + end + + subject { described_class.supported_sizes(name) } + + with_them do + it { expect(described_class.supported_sizes(name)).to eq(sizes) } + it { expect(described_class.supported_sizes(name.to_s)).to eq(sizes) } + end + end + + describe '#valid?' do + subject { public_key } + + context 'with a valid SSH key' do + it { is_expected.to be_valid } + end + + context 'with an invalid SSH key' do + let(:key) { 'this is not a key' } + + it { is_expected.not_to be_valid } + end + end + + describe '#type' do + subject { public_key.type } + + where(:factory, :type) do + [ + [:rsa_key_2048, :rsa], + [:dsa_key_2048, :dsa], + [:ecdsa_key_256, :ecdsa], + [:ed25519_key_256, :ed25519] + ] + end + + with_them do + let(:key) { attributes_for(factory)[:key] } + + it { is_expected.to eq(type) } + end + + context 'with an invalid SSH key' do + let(:key) { 'this is not a key' } + + it { is_expected.to be_nil } + end + end + + describe '#bits' do + subject { public_key.bits } + + where(:factory, :bits) do + [ + [:rsa_key_2048, 2048], + [:dsa_key_2048, 2048], + [:ecdsa_key_256, 256], + [:ed25519_key_256, 256] + ] + end + + with_them do + let(:key) { attributes_for(factory)[:key] } + + it { is_expected.to eq(bits) } + end + + context 'with an invalid SSH key' do + let(:key) { 'this is not a key' } + + it { is_expected.to be_nil } + end + end + + describe '#fingerprint' do + subject { public_key.fingerprint } + + where(:factory, :fingerprint) do + [ + [:rsa_key_2048, '2e:ca:dc:e0:37:29:ed:fc:f0:1d:bf:66:d4:cd:51:b1'], + [:dsa_key_2048, 'bc:c1:a4:be:7e:8c:84:56:b3:58:93:53:c6:80:78:8c'], + [:ecdsa_key_256, '67:a3:a9:7d:b8:e1:15:d4:80:40:21:34:bb:ed:97:38'], + [:ed25519_key_256, 'e6:eb:45:8a:3c:59:35:5f:e9:5b:80:12:be:7e:22:73'] + ] + end + + with_them do + let(:key) { attributes_for(factory)[:key] } + + it { is_expected.to eq(fingerprint) } + end + + context 'with an invalid SSH key' do + let(:key) { 'this is not a key' } + + it { is_expected.to be_nil } + end + end + + describe '#key_text' do + let(:key) { 'this is not a key' } + + it 'carries the unmodified key data' do + expect(public_key.key_text).to eq(key) + end + end +end diff --git a/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb index 6541326d1de..e2fa76522bc 100644 --- a/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb +++ b/spec/lib/gitlab/template/gitlab_ci_yml_template_spec.rb @@ -30,6 +30,14 @@ describe Gitlab::Template::GitlabCiYmlTemplate do end end + describe '.by_category' do + it 'returns sorted results' do + result = described_class.by_category('General') + + expect(result).to eq(result.sort) + end + end + describe '#content' do it 'loads the full file' do gitignore = subject.new(Rails.root.join('vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml')) @@ -38,4 +46,14 @@ describe Gitlab::Template::GitlabCiYmlTemplate do expect(gitignore.content).to start_with('#') end end + + describe '#<=>' do + it 'sorts lexicographically' do + one = described_class.new('a.gitlab-ci.yml') + other = described_class.new('z.gitlab-ci.yml') + + expect(one.<=>(other)).to be(-1) + expect([other, one].sort).to eq([one, other]) + end + end end diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb index 92787bb262e..3137a72fdc4 100644 --- a/spec/lib/gitlab/utils_spec.rb +++ b/spec/lib/gitlab/utils_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Gitlab::Utils do - delegate :to_boolean, :boolean_to_yes_no, :slugify, to: :described_class + delegate :to_boolean, :boolean_to_yes_no, :slugify, :random_string, to: :described_class describe '.slugify' do { @@ -53,4 +53,10 @@ describe Gitlab::Utils do expect(boolean_to_yes_no(false)).to eq('No') end end + + describe '.random_string' do + it 'generates a string' do + expect(random_string).to be_kind_of(String) + end + end end diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index b66afafa174..699184ad9fe 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -228,21 +228,10 @@ describe Gitlab::Workhorse do let(:action) { 'git_upload_pack' } let(:feature_flag) { :post_upload_pack } - context 'when action is enabled by feature flag' do - it 'includes Gitaly params in the returned value' do - allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(feature_flag).and_return(true) + it 'includes Gitaly params in the returned value' do + allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(feature_flag).and_return(true) - expect(subject).to include(gitaly_params) - end - end - - context 'when action is not enabled by feature flag' do - it 'does not include Gitaly params in the returned value' do - status_opt_out = Gitlab::GitalyClient::MigrationStatus::OPT_OUT - allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(feature_flag, status: status_opt_out).and_return(false) - - expect(subject).not_to include(gitaly_params) - end + expect(subject).to include(gitaly_params) end end diff --git a/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb b/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb new file mode 100644 index 00000000000..7125bfcab59 --- /dev/null +++ b/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe SystemCheck::App::GitUserDefaultSSHConfigCheck do + let(:username) { '_this_user_will_not_exist_unless_it_is_stubbed' } + let(:base_dir) { Dir.mktmpdir } + let(:home_dir) { File.join(base_dir, "/var/lib/#{username}") } + let(:ssh_dir) { File.join(home_dir, '.ssh') } + let(:forbidden_file) { 'id_rsa' } + + before do + allow(Gitlab.config.gitlab).to receive(:user).and_return(username) + end + + after do + FileUtils.rm_rf(base_dir) + end + + it 'only whitelists safe files' do + expect(described_class::WHITELIST).to contain_exactly('authorized_keys', 'authorized_keys2', 'known_hosts') + end + + describe '#skip?' do + subject { described_class.new.skip? } + + where(user_exists: [true, false], home_dir_exists: [true, false]) + + with_them do + let(:expected_result) { !user_exists || !home_dir_exists } + + before do + stub_user if user_exists + stub_home_dir if home_dir_exists + end + + it { is_expected.to eq(expected_result) } + end + end + + describe '#check?' do + subject { described_class.new.check? } + + before do + stub_user + end + + it 'fails if a forbidden file exists' do + stub_ssh_file(forbidden_file) + + is_expected.to be_falsy + end + + it "succeeds if the SSH directory doesn't exist" do + FileUtils.rm_rf(ssh_dir) + + is_expected.to be_truthy + end + + it 'succeeds if all the whitelisted files exist' do + described_class::WHITELIST.each do |filename| + stub_ssh_file(filename) + end + + is_expected.to be_truthy + end + end + + def stub_user + allow(File).to receive(:expand_path).with("~#{username}").and_return(home_dir) + end + + def stub_home_dir + FileUtils.mkdir_p(home_dir) + end + + def stub_ssh_file(filename) + FileUtils.mkdir_p(ssh_dir) + FileUtils.touch(File.join(ssh_dir, filename)) + end +end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 1fa59ebd22b..932e2fd8c95 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -8,6 +8,25 @@ describe Notify do include_context 'gitlab email notification' + set(:user) { create(:user) } + set(:current_user) { create(:user, email: "current@email.com") } + set(:assignee) { create(:user, email: 'assignee@example.com', name: 'John Doe') } + + set(:merge_request) do + create(:merge_request, source_project: project, + target_project: project, + author: current_user, + assignee: assignee, + description: 'Awesome description') + end + + set(:issue) do + create(:issue, author: current_user, + assignees: [assignee], + project: project, + description: 'My awesome description!') + end + def have_referable_subject(referable, reply: false) prefix = referable.project.name if referable.project prefix = "Re: #{prefix}" if reply @@ -19,8 +38,6 @@ describe Notify do context 'for a project' do describe 'items that are assignable, the email' do - let(:current_user) { create(:user, email: "current@email.com") } - let(:assignee) { create(:user, email: 'assignee@example.com', name: 'John Doe') } let(:previous_assignee) { create(:user, name: 'Previous Assignee') } shared_examples 'an assignee email' do @@ -36,9 +53,6 @@ describe Notify do end context 'for issues' do - let(:issue) { create(:issue, author: current_user, assignees: [assignee], project: project) } - let(:issue_with_description) { create(:issue, author: current_user, assignees: [assignee], project: project, description: 'My awesome description') } - describe 'that are new' do subject { described_class.new_issue_email(issue.assignees.first.id, issue.id) } @@ -56,6 +70,10 @@ describe Notify do end end + it 'contains the description' do + is_expected.to have_html_escaped_body_text issue.description + end + context 'when enabled email_author_in_body' do before do stub_application_setting(email_author_in_body: true) @@ -68,16 +86,6 @@ describe Notify do end end - describe 'that are new with a description' do - subject { described_class.new_issue_email(issue_with_description.assignees.first.id, issue_with_description.id) } - - it_behaves_like 'it should show Gmail Actions View Issue link' - - it 'contains the description' do - is_expected.to have_html_escaped_body_text issue_with_description.description - end - end - describe 'that have been reassigned' do subject { described_class.reassigned_issue_email(recipient.id, issue.id, [previous_assignee.id], current_user.id) } @@ -197,11 +205,6 @@ describe Notify do end context 'for merge requests' do - let(:project) { create(:project, :repository) } - let(:merge_author) { create(:user) } - let(:merge_request) { create(:merge_request, author: current_user, assignee: assignee, source_project: project, target_project: project) } - let(:merge_request_with_description) { create(:merge_request, author: current_user, assignee: assignee, source_project: project, target_project: project, description: 'My awesome description') } - describe 'that are new' do subject { described_class.new_merge_request_email(merge_request.assignee_id, merge_request.id) } @@ -221,6 +224,10 @@ describe Notify do end end + it 'contains the description' do + is_expected.to have_html_escaped_body_text merge_request.description + end + context 'when enabled email_author_in_body' do before do stub_application_setting(email_author_in_body: true) @@ -233,17 +240,6 @@ describe Notify do end end - describe 'that are new with a description' do - subject { described_class.new_merge_request_email(merge_request_with_description.assignee_id, merge_request_with_description.id) } - - it_behaves_like 'it should show Gmail Actions View Merge request link' - it_behaves_like "an unsubscribeable thread" - - it 'contains the description' do - is_expected.to have_html_escaped_body_text merge_request_with_description.description - end - end - describe 'that are reassigned' do subject { described_class.reassigned_merge_request_email(recipient.id, merge_request.id, previous_assignee.id, current_user.id) } @@ -321,6 +317,7 @@ describe Notify do end describe 'that are merged' do + let(:merge_author) { create(:user) } subject { described_class.merged_merge_request_email(recipient.id, merge_request.id, merge_author.id) } it_behaves_like 'a multiple recipients email' @@ -348,8 +345,6 @@ describe Notify do end describe 'project was moved' do - let(:project) { create(:project) } - let(:user) { create(:user) } subject { described_class.project_was_moved_email(project.id, user.id, "gitlab/gitlab") } it_behaves_like 'an email sent from GitLab' @@ -371,7 +366,6 @@ describe Notify do end end - let(:user) { create(:user) } let(:project_member) do project.request_access(user) project.requesters.find_by(user_id: user.id) @@ -398,7 +392,6 @@ describe Notify do let(:group_owner) { create(:user) } let(:group) { create(:group).tap { |g| g.add_owner(group_owner) } } let(:project) { create(:project, :public, :access_requestable, namespace: group) } - let(:user) { create(:user) } let(:project_member) do project.request_access(user) project.requesters.find_by(user_id: user.id) @@ -424,7 +417,6 @@ describe Notify do describe 'project access denied' do let(:project) { create(:project, :public, :access_requestable) } - let(:user) { create(:user) } let(:project_member) do project.request_access(user) project.requesters.find_by(user_id: user.id) @@ -445,7 +437,6 @@ describe Notify do describe 'project access changed' do let(:owner) { create(:user, name: "Chang O'Keefe") } let(:project) { create(:project, :public, :access_requestable, namespace: owner.namespace) } - let(:user) { create(:user) } let(:project_member) { create(:project_member, project: project, user: user) } subject { described_class.member_access_granted_email('project', project_member.id) } @@ -474,7 +465,6 @@ describe Notify do end describe 'project invitation' do - let(:project) { create(:project) } let(:master) { create(:user).tap { |u| project.team << [u, :master] } } let(:project_member) { invite_to_project(project, inviter: master) } @@ -494,7 +484,6 @@ describe Notify do end describe 'project invitation accepted' do - let(:project) { create(:project) } let(:invited_user) { create(:user, name: 'invited user') } let(:master) { create(:user).tap { |u| project.team << [u, :master] } } let(:project_member) do @@ -519,7 +508,6 @@ describe Notify do end describe 'project invitation declined' do - let(:project) { create(:project) } let(:master) { create(:user).tap { |u| project.team << [u, :master] } } let(:project_member) do invitee = invite_to_project(project, inviter: master) @@ -582,7 +570,6 @@ describe Notify do end describe 'on a commit' do - let(:project) { create(:project, :repository) } let(:commit) { project.commit } before do @@ -607,7 +594,6 @@ describe Notify do end describe 'on a merge request' do - let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } let(:note_on_merge_request_path) { project_merge_request_path(project, merge_request, anchor: "note_#{note.id}") } before do @@ -632,7 +618,6 @@ describe Notify do end describe 'on an issue' do - let(:issue) { create(:issue, project: project) } let(:note_on_issue_path) { project_issue_path(project, issue, anchor: "note_#{note.id}") } before do @@ -658,7 +643,6 @@ describe Notify do end context 'items that are noteable, the email for a discussion note' do - let(:project) { create(:project, :repository) } let(:note_author) { create(:user, name: 'author_name') } before do @@ -722,7 +706,6 @@ describe Notify do end describe 'on a merge request' do - let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } let(:note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project, author: note_author) } let(:note_on_merge_request_path) { project_merge_request_path(project, merge_request, anchor: "note_#{note.id}") } @@ -749,7 +732,6 @@ describe Notify do end describe 'on an issue' do - let(:issue) { create(:issue, project: project) } let(:note) { create(:discussion_note_on_issue, noteable: issue, project: project, author: note_author) } let(:note_on_issue_path) { project_issue_path(project, issue, anchor: "note_#{note.id}") } @@ -835,7 +817,6 @@ describe Notify do end describe 'on a merge request' do - let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } let(:note) { create(:diff_note_on_merge_request) } subject { described_class.note_merge_request_email(recipient.id, note.id) } @@ -848,9 +829,10 @@ describe Notify do end context 'for a group' do + set(:group) { create(:group) } + describe 'group access requested' do let(:group) { create(:group, :public, :access_requestable) } - let(:user) { create(:user) } let(:group_member) do group.request_access(user) group.requesters.find_by(user_id: user.id) @@ -870,8 +852,6 @@ describe Notify do end describe 'group access denied' do - let(:group) { create(:group) } - let(:user) { create(:user) } let(:group_member) do group.request_access(user) group.requesters.find_by(user_id: user.id) @@ -890,8 +870,6 @@ describe Notify do end describe 'group access changed' do - let(:group) { create(:group) } - let(:user) { create(:user) } let(:group_member) { create(:group_member, group: group, user: user) } subject { described_class.member_access_granted_email('group', group_member.id) } @@ -921,7 +899,6 @@ describe Notify do end describe 'group invitation' do - let(:group) { create(:group) } let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } let(:group_member) { invite_to_group(group, inviter: owner) } @@ -941,7 +918,6 @@ describe Notify do end describe 'group invitation accepted' do - let(:group) { create(:group) } let(:invited_user) { create(:user, name: 'invited user') } let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } let(:group_member) do @@ -966,7 +942,6 @@ describe Notify do end describe 'group invitation declined' do - let(:group) { create(:group) } let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } let(:group_member) do invitee = invite_to_group(group, inviter: owner) @@ -1020,7 +995,6 @@ describe Notify do describe 'email on push for a created branch' do let(:example_site_path) { root_path } - let(:user) { create(:user) } let(:tree_path) { project_tree_path(project, "empty-branch") } subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/empty-branch', action: :create) } @@ -1046,7 +1020,6 @@ describe Notify do describe 'email on push for a created tag' do let(:example_site_path) { root_path } - let(:user) { create(:user) } let(:tree_path) { project_tree_path(project, "v1.0") } subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/tags/v1.0', action: :create) } @@ -1072,7 +1045,6 @@ describe Notify do describe 'email on push for a deleted branch' do let(:example_site_path) { root_path } - let(:user) { create(:user) } subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/heads/master', action: :delete) } @@ -1094,7 +1066,6 @@ describe Notify do describe 'email on push for a deleted tag' do let(:example_site_path) { root_path } - let(:user) { create(:user) } subject { described_class.repository_push_email(project.id, author_id: user.id, ref: 'refs/tags/v1.0', action: :delete) } @@ -1115,9 +1086,7 @@ describe Notify do end describe 'email on push with multiple commits' do - let(:project) { create(:project, :repository) } let(:example_site_path) { root_path } - let(:user) { create(:user) } let(:raw_compare) { Gitlab::Git::Compare.new(project.repository.raw_repository, sample_image_commit.id, sample_commit.id) } let(:compare) { Compare.decorate(raw_compare, project) } let(:commits) { compare.commits } @@ -1209,9 +1178,7 @@ describe Notify do end describe 'email on push with a single commit' do - let(:project) { create(:project, :repository) } let(:example_site_path) { root_path } - let(:user) { create(:user) } let(:raw_compare) { Gitlab::Git::Compare.new(project.repository.raw_repository, sample_commit.parent_id, sample_commit.id) } let(:compare) { Compare.decorate(raw_compare, project) } let(:commits) { compare.commits } @@ -1242,8 +1209,6 @@ describe Notify do end describe 'HTML emails setting' do - let(:project) { create(:project) } - let(:user) { create(:user) } let(:multipart_mail) { described_class.project_was_moved_email(project.id, user.id, "gitlab/gitlab") } context 'when disabled' do diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 359753b600e..f921545668d 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -72,6 +72,33 @@ describe ApplicationSetting do .is_greater_than(0) end + context 'key restrictions' do + it 'supports all key types' do + expect(described_class::SUPPORTED_KEY_TYPES).to contain_exactly(:rsa, :dsa, :ecdsa, :ed25519) + end + + it 'does not allow all key types to be disabled' do + described_class::SUPPORTED_KEY_TYPES.each do |type| + setting["#{type}_key_restriction"] = described_class::FORBIDDEN_KEY_VALUE + end + + expect(setting).not_to be_valid + expect(setting.errors.messages).to have_key(:allowed_key_types) + end + + where(:type) do + described_class::SUPPORTED_KEY_TYPES + end + + with_them do + let(:field) { :"#{type}_key_restriction" } + + it { is_expected.to validate_presence_of(field) } + it { is_expected.to allow_value(*KeyRestrictionValidator.supported_key_restrictions(type)).for(field) } + it { is_expected.not_to allow_value(128).for(field) } + end + end + it_behaves_like 'an object with email-formated attributes', :admin_notification_email do subject { setting } end @@ -441,4 +468,36 @@ describe ApplicationSetting do end end end + + describe '#allowed_key_types' do + it 'includes all key types by default' do + expect(setting.allowed_key_types).to contain_exactly(*described_class::SUPPORTED_KEY_TYPES) + end + + it 'excludes disabled key types' do + expect(setting.allowed_key_types).to include(:ed25519) + + setting.ed25519_key_restriction = described_class::FORBIDDEN_KEY_VALUE + + expect(setting.allowed_key_types).not_to include(:ed25519) + end + end + + describe '#key_restriction_for' do + it 'returns the restriction value for recognised types' do + setting.rsa_key_restriction = 1024 + + expect(setting.key_restriction_for(:rsa)).to eq(1024) + end + + it 'allows types to be passed as a string' do + setting.rsa_key_restriction = 1024 + + expect(setting.key_restriction_for('rsa')).to eq(1024) + end + + it 'returns forbidden for unrecognised type' do + expect(setting.key_restriction_for(:foo)).to eq(described_class::FORBIDDEN_KEY_VALUE) + end + end end diff --git a/spec/models/award_emoji_spec.rb b/spec/models/award_emoji_spec.rb index 87e60d9c16b..b909e04dfc3 100644 --- a/spec/models/award_emoji_spec.rb +++ b/spec/models/award_emoji_spec.rb @@ -41,4 +41,40 @@ describe AwardEmoji do end end end + + describe 'expiring ETag cache' do + context 'on a note' do + let(:note) { create(:note_on_issue) } + let(:award_emoji) { build(:award_emoji, user: build(:user), awardable: note) } + + it 'calls expire_etag_cache on the note when saved' do + expect(note).to receive(:expire_etag_cache) + + award_emoji.save! + end + + it 'calls expire_etag_cache on the note when destroyed' do + expect(note).to receive(:expire_etag_cache) + + award_emoji.destroy! + end + end + + context 'on another awardable' do + let(:issue) { create(:issue) } + let(:award_emoji) { build(:award_emoji, user: build(:user), awardable: issue) } + + it 'does not call expire_etag_cache on the issue when saved' do + expect(issue).not_to receive(:expire_etag_cache) + + award_emoji.save! + end + + it 'does not call expire_etag_cache on the issue when destroyed' do + expect(issue).not_to receive(:expire_etag_cache) + + award_emoji.destroy! + end + end + end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 0c35ad3c9d8..08d22f166e4 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -43,6 +43,32 @@ describe Ci::Build do it { is_expected.not_to include(manual_but_created) } end + describe '.ref_protected' do + subject { described_class.ref_protected } + + context 'when protected is true' do + let!(:job) { create(:ci_build, :protected) } + + it { is_expected.to include(job) } + end + + context 'when protected is false' do + let!(:job) { create(:ci_build) } + + it { is_expected.not_to include(job) } + end + + context 'when protected is nil' do + let!(:job) { create(:ci_build) } + + before do + job.update_attribute(:protected, nil) + end + + it { is_expected.not_to include(job) } + end + end + describe '#actionize' do context 'when build is a created' do before do @@ -1466,10 +1492,12 @@ describe Ci::Build do context 'when build is for triggers' do let(:trigger) { create(:ci_trigger, project: project) } - let(:trigger_request) { create(:ci_trigger_request_with_variables, pipeline: pipeline, trigger: trigger) } + let(:trigger_request) { create(:ci_trigger_request, pipeline: pipeline, trigger: trigger) } + let(:user_trigger_variable) do - { key: :TRIGGER_KEY_1, value: 'TRIGGER_VALUE_1', public: false } + { key: 'TRIGGER_KEY_1', value: 'TRIGGER_VALUE_1', public: false } end + let(:predefined_trigger_variable) do { key: 'CI_PIPELINE_TRIGGERED', value: 'true', public: true } end @@ -1478,8 +1506,26 @@ describe Ci::Build do build.trigger_request = trigger_request end - it { is_expected.to include(user_trigger_variable) } - it { is_expected.to include(predefined_trigger_variable) } + shared_examples 'returns variables for triggers' do + it { is_expected.to include(user_trigger_variable) } + it { is_expected.to include(predefined_trigger_variable) } + end + + context 'when variables are stored in trigger_request' do + before do + trigger_request.update_attribute(:variables, { 'TRIGGER_KEY_1' => 'TRIGGER_VALUE_1' } ) + end + + it_behaves_like 'returns variables for triggers' + end + + context 'when variables are stored in pipeline_variables' do + before do + create(:ci_pipeline_variable, pipeline: pipeline, key: 'TRIGGER_KEY_1', value: 'TRIGGER_VALUE_1') + end + + it_behaves_like 'returns variables for triggers' + end end context 'when pipeline has a variable' do diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index b84e3ff18e8..84656ffe0b9 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -546,6 +546,22 @@ describe Ci::Pipeline, :mailer do end end + describe '#has_kubernetes_active?' do + context 'when kubernetes is active' do + let(:project) { create(:kubernetes_project) } + + it 'returns true' do + expect(pipeline).to have_kubernetes_active + end + end + + context 'when kubernetes is not active' do + it 'returns false' do + expect(pipeline).not_to have_kubernetes_active + end + end + end + describe '#has_stage_seeds?' do context 'when pipeline has stage seeds' do subject { build(:ci_pipeline_with_one_job) } diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index 48f878bbee6..2e686e515c5 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -2,6 +2,8 @@ require 'spec_helper' describe Ci::Runner do describe 'validation' do + it { is_expected.to validate_presence_of(:access_level) } + context 'when runner is not allowed to pick untagged jobs' do context 'when runner does not have tags' do it 'is not valid' do @@ -19,6 +21,34 @@ describe Ci::Runner do end end + describe '#access_level' do + context 'when creating new runner and access_level is nil' do + let(:runner) do + build(:ci_runner, access_level: nil) + end + + it "object is invalid" do + expect(runner).not_to be_valid + end + end + + context 'when creating new runner and access_level is defined in enum' do + let(:runner) do + build(:ci_runner, access_level: :not_protected) + end + + it "object is valid" do + expect(runner).to be_valid + end + end + + context 'when creating new runner and access_level is not defined in enum' do + it "raises an error" do + expect { build(:ci_runner, access_level: :this_is_not_defined) }.to raise_error(ArgumentError) + end + end + end + describe '#display_name' do it 'returns the description if it has a value' do runner = FactoryGirl.build(:ci_runner, description: 'Linux/Ruby-1.9.3-p448') @@ -95,6 +125,8 @@ describe Ci::Runner do let(:build) { create(:ci_build, pipeline: pipeline) } let(:runner) { create(:ci_runner) } + subject { runner.can_pick?(build) } + before do build.project.runners << runner end @@ -222,6 +254,50 @@ describe Ci::Runner do end end end + + context 'when access_level of runner is not_protected' do + before do + runner.not_protected! + end + + context 'when build is protected' do + before do + build.protected = true + end + + it { is_expected.to be_truthy } + end + + context 'when build is unprotected' do + before do + build.protected = false + end + + it { is_expected.to be_truthy } + end + end + + context 'when access_level of runner is ref_protected' do + before do + runner.ref_protected! + end + + context 'when build is protected' do + before do + build.protected = true + end + + it { is_expected.to be_truthy } + end + + context 'when build is unprotected' do + before do + build.protected = false + end + + it { is_expected.to be_falsey } + end + end end describe '#status' do diff --git a/spec/models/ci/trigger_request_spec.rb b/spec/models/ci/trigger_request_spec.rb new file mode 100644 index 00000000000..7dcf3528f73 --- /dev/null +++ b/spec/models/ci/trigger_request_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe Ci::TriggerRequest do + describe 'validation' do + it 'be invalid if saving a variable' do + trigger = build(:ci_trigger_request, variables: { TRIGGER_KEY_1: 'TRIGGER_VALUE_1' } ) + + expect(trigger).not_to be_valid + end + + it 'be valid if not saving a variable' do + trigger = build(:ci_trigger_request) + + expect(trigger).to be_valid + end + end +end diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index c18c635d811..11e64a0f877 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -195,6 +195,67 @@ eos it { expect(data[:removed]).to eq([]) } end + describe '#cherry_pick_message' do + let(:user) { create(:user) } + + context 'of a regular commit' do + let(:commit) { project.commit('video') } + + it { expect(commit.cherry_pick_message(user)).to include("\n\n(cherry picked from commit 88790590ed1337ab189bccaa355f068481c90bec)") } + end + + context 'of a merge commit' do + let(:repository) { project.repository } + + let(:commit_options) do + author = repository.user_to_committer(user) + { message: 'Test message', committer: author, author: author } + end + + let(:merge_request) do + create(:merge_request, + source_branch: 'video', + target_branch: 'master', + source_project: project, + author: user) + end + + let(:merge_commit) do + merge_commit_id = repository.merge(user, + merge_request.diff_head_sha, + merge_request, + commit_options) + + repository.commit(merge_commit_id) + end + + context 'that is found' do + before do + # Artificially mark as completed. + merge_request.update(merge_commit_sha: merge_commit.id) + end + + it do + expected_appended_text = <<~STR.rstrip + + (cherry picked from commit #{merge_commit.sha}) + + 467dc98f Add new 'videos' directory + 88790590 Upload new video file + STR + + expect(merge_commit.cherry_pick_message(user)).to include(expected_appended_text) + end + end + + context "that is existing but not found" do + it 'does not include details of the merged commits' do + expect(merge_commit.cherry_pick_message(user)).to end_with("(cherry picked from commit #{merge_commit.sha})") + end + end + end + end + describe '#reverts_commit?' do let(:another_commit) { double(:commit, revert_description: "This reverts commit #{commit.sha}") } let(:user) { commit.author } diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index f7583645e69..858ec831200 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -443,4 +443,25 @@ describe CommitStatus do end end end + + describe 'set failure_reason when drop' do + let(:commit_status) { create(:commit_status, :created) } + + subject do + commit_status.drop!(reason) + commit_status + end + + context 'when failure_reason is nil' do + let(:reason) { } + + it { is_expected.to be_unknown_failure } + end + + context 'when failure_reason is script_failure' do + let(:reason) { :script_failure } + + it { is_expected.to be_script_failure } + end + end end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index dfbe1a7c192..37f6fd3a25b 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -66,56 +66,76 @@ describe Issuable do end describe ".search" do - let!(:searchable_issue) { create(:issue, title: "Searchable issue") } + let!(:searchable_issue) { create(:issue, title: "Searchable awesome issue") } - it 'returns notes with a matching title' do + it 'returns issues with a matching title' do expect(issuable_class.search(searchable_issue.title)) .to eq([searchable_issue]) end - it 'returns notes with a partially matching title' do + it 'returns issues with a partially matching title' do expect(issuable_class.search('able')).to eq([searchable_issue]) end - it 'returns notes with a matching title regardless of the casing' do + it 'returns issues with a matching title regardless of the casing' do expect(issuable_class.search(searchable_issue.title.upcase)) .to eq([searchable_issue]) end + + it 'returns issues with a fuzzy matching title' do + expect(issuable_class.search('searchable issue')).to eq([searchable_issue]) + end + + it 'returns all issues with a query shorter than 3 chars' do + expect(issuable_class.search('zz')).to eq(issuable_class.all) + end end describe ".full_search" do let!(:searchable_issue) do - create(:issue, title: "Searchable issue", description: 'kittens') + create(:issue, title: "Searchable awesome issue", description: 'Many cute kittens') end - it 'returns notes with a matching title' do + it 'returns issues with a matching title' do expect(issuable_class.full_search(searchable_issue.title)) .to eq([searchable_issue]) end - it 'returns notes with a partially matching title' do + it 'returns issues with a partially matching title' do expect(issuable_class.full_search('able')).to eq([searchable_issue]) end - it 'returns notes with a matching title regardless of the casing' do + it 'returns issues with a matching title regardless of the casing' do expect(issuable_class.full_search(searchable_issue.title.upcase)) .to eq([searchable_issue]) end - it 'returns notes with a matching description' do + it 'returns issues with a fuzzy matching title' do + expect(issuable_class.full_search('searchable issue')).to eq([searchable_issue]) + end + + it 'returns issues with a matching description' do expect(issuable_class.full_search(searchable_issue.description)) .to eq([searchable_issue]) end - it 'returns notes with a partially matching description' do + it 'returns issues with a partially matching description' do expect(issuable_class.full_search(searchable_issue.description)) .to eq([searchable_issue]) end - it 'returns notes with a matching description regardless of the casing' do + it 'returns issues with a matching description regardless of the casing' do expect(issuable_class.full_search(searchable_issue.description.upcase)) .to eq([searchable_issue]) end + + it 'returns issues with a fuzzy matching description' do + expect(issuable_class.full_search('many kittens')).to eq([searchable_issue]) + end + + it 'returns all issues with a query shorter than 3 chars' do + expect(issuable_class.search('zz')).to eq(issuable_class.all) + end end describe '.to_ability_name' do diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb index bae88cb1d24..e46945e301e 100644 --- a/spec/models/container_repository_spec.rb +++ b/spec/models/container_repository_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe ContainerRepository do let(:group) { create(:group, name: 'group') } - let(:project) { create(:project, :repository, path: 'test', group: group) } + let(:project) { create(:project, path: 'test', group: group) } let(:repository) do create(:container_repository, name: 'my_image', project: project) diff --git a/spec/models/gpg_key_spec.rb b/spec/models/gpg_key_spec.rb index e48f20bf53b..9c99c3e5c08 100644 --- a/spec/models/gpg_key_spec.rb +++ b/spec/models/gpg_key_spec.rb @@ -99,14 +99,14 @@ describe GpgKey do end describe '#verified?' do - it 'returns true one of the email addresses in the key belongs to the user' do + it 'returns true if one of the email addresses in the key belongs to the user' do user = create :user, email: 'bette.cartwright@example.com' gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user expect(gpg_key.verified?).to be_truthy end - it 'returns false if one of the email addresses in the key does not belong to the user' do + it 'returns false if none of the email addresses in the key does not belong to the user' do user = create :user, email: 'someone.else@example.com' gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user @@ -114,6 +114,32 @@ describe GpgKey do end end + describe 'verified_and_belongs_to_email?' do + it 'returns false if none of the email addresses in the key does not belong to the user' do + user = create :user, email: 'someone.else@example.com' + gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user + + expect(gpg_key.verified?).to be_falsey + expect(gpg_key.verified_and_belongs_to_email?('someone.else@example.com')).to be_falsey + end + + it 'returns false if one of the email addresses in the key belongs to the user and does not match the provided email' do + user = create :user, email: 'bette.cartwright@example.com' + gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user + + expect(gpg_key.verified?).to be_truthy + expect(gpg_key.verified_and_belongs_to_email?('bette.cartwright@example.net')).to be_falsey + end + + it 'returns true if one of the email addresses in the key belongs to the user and matches the provided email' do + user = create :user, email: 'bette.cartwright@example.com' + gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key, user: user + + expect(gpg_key.verified?).to be_truthy + expect(gpg_key.verified_and_belongs_to_email?('bette.cartwright@example.com')).to be_truthy + end + end + describe 'notification', :mailer do let(:user) { create(:user) } @@ -129,15 +155,15 @@ describe GpgKey do describe '#revoke' do it 'invalidates all associated gpg signatures and destroys the key' do gpg_key = create :gpg_key - gpg_signature = create :gpg_signature, valid_signature: true, gpg_key: gpg_key + gpg_signature = create :gpg_signature, verification_status: :verified, gpg_key: gpg_key unrelated_gpg_key = create :gpg_key, key: GpgHelpers::User2.public_key - unrelated_gpg_signature = create :gpg_signature, valid_signature: true, gpg_key: unrelated_gpg_key + unrelated_gpg_signature = create :gpg_signature, verification_status: :verified, gpg_key: unrelated_gpg_key gpg_key.revoke expect(gpg_signature.reload).to have_attributes( - valid_signature: false, + verification_status: 'unknown_key', gpg_key: nil ) @@ -145,7 +171,7 @@ describe GpgKey do # unrelated signature is left untouched expect(unrelated_gpg_signature.reload).to have_attributes( - valid_signature: true, + verification_status: 'verified', gpg_key: unrelated_gpg_key ) diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index 3508391c721..96baeaff0a4 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -1,6 +1,13 @@ require 'spec_helper' describe Key, :mailer do + include Gitlab::CurrentSettings + + describe 'modules' do + subject { described_class } + it { is_expected.to include_module(Gitlab::CurrentSettings) } + end + describe "Associations" do it { is_expected.to belong_to(:user) } end @@ -11,8 +18,10 @@ describe Key, :mailer do it { is_expected.to validate_presence_of(:key) } it { is_expected.to validate_length_of(:key).is_at_most(5000) } - it { is_expected.to allow_value('ssh-foo').for(:key) } - it { is_expected.to allow_value('ecdsa-foo').for(:key) } + it { is_expected.to allow_value(attributes_for(:rsa_key_2048)[:key]).for(:key) } + it { is_expected.to allow_value(attributes_for(:dsa_key_2048)[:key]).for(:key) } + it { is_expected.to allow_value(attributes_for(:ecdsa_key_256)[:key]).for(:key) } + it { is_expected.to allow_value(attributes_for(:ed25519_key_256)[:key]).for(:key) } it { is_expected.not_to allow_value('foo-bar').for(:key) } end @@ -95,6 +104,48 @@ describe Key, :mailer do end end + context 'validate it meets key restrictions' do + where(:factory, :minimum, :result) do + forbidden = ApplicationSetting::FORBIDDEN_KEY_VALUE + + [ + [:rsa_key_2048, 0, true], + [:dsa_key_2048, 0, true], + [:ecdsa_key_256, 0, true], + [:ed25519_key_256, 0, true], + + [:rsa_key_2048, 1024, true], + [:rsa_key_2048, 2048, true], + [:rsa_key_2048, 4096, false], + + [:dsa_key_2048, 1024, true], + [:dsa_key_2048, 2048, true], + [:dsa_key_2048, 4096, false], + + [:ecdsa_key_256, 256, true], + [:ecdsa_key_256, 384, false], + + [:ed25519_key_256, 256, true], + [:ed25519_key_256, 384, false], + + [:rsa_key_2048, forbidden, false], + [:dsa_key_2048, forbidden, false], + [:ecdsa_key_256, forbidden, false], + [:ed25519_key_256, forbidden, false] + ] + end + + with_them do + subject(:key) { build(factory) } + + before do + stub_application_setting("#{key.public_key.type}_key_restriction" => minimum) + end + + it { expect(key.valid?).to eq(result) } + end + end + context 'callbacks' do it 'adds new key to authorized_file' do key = build(:personal_key, id: 7) diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 09f3b97ec58..f5d079c27c4 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -159,6 +159,7 @@ describe MergeRequest do before do subject.project.has_external_issue_tracker = true subject.project.save! + create(:jira_service, project: subject.project) end it 'does not cache issues from external trackers' do @@ -166,6 +167,7 @@ describe MergeRequest do commit = double('commit1', safe_message: "Fixes #{issue.to_reference}") allow(subject).to receive(:commits).and_return([commit]) + expect { subject.cache_merge_request_closes_issues!(subject.author) }.not_to raise_error expect { subject.cache_merge_request_closes_issues!(subject.author) }.not_to change(subject.merge_requests_closing_issues, :count) end diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb index b1743cd608e..537cdadd528 100644 --- a/spec/models/project_services/kubernetes_service_spec.rb +++ b/spec/models/project_services/kubernetes_service_spec.rb @@ -203,18 +203,13 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do describe '#predefined_variables' do let(:kubeconfig) do - config = - YAML.load(File.read(expand_fixture_path('config/kubeconfig.yml'))) - - config.dig('users', 0, 'user')['token'] = - 'token' - + config_file = expand_fixture_path('config/kubeconfig.yml') + config = YAML.load(File.read(config_file)) + config.dig('users', 0, 'user')['token'] = 'token' + config.dig('contexts', 0, 'context')['namespace'] = namespace config.dig('clusters', 0, 'cluster')['certificate-authority-data'] = Base64.encode64('CA PEM DATA') - config.dig('contexts', 0, 'context')['namespace'] = - namespace - YAML.dump(config) end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 3621023f4ca..be1ae295f75 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -181,7 +181,7 @@ describe Project do end end - context 'repository storages inclussion' do + context 'repository storages inclusion' do let(:project2) { build(:project, repository_storage: 'missing') } before do diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 34e1a955309..7065d467ec0 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -886,7 +886,7 @@ describe Repository, models: true do context 'when pre hooks were successful' do it 'runs without errors' do expect_any_instance_of(Gitlab::Git::HooksService).to receive(:execute) - .with(committer, repository, old_rev, blank_sha, 'refs/heads/feature') + .with(committer, repository.raw_repository, old_rev, blank_sha, 'refs/heads/feature') expect { repository.rm_branch(user, 'feature') }.not_to raise_error end @@ -932,20 +932,20 @@ describe Repository, models: true do service = Gitlab::Git::HooksService.new expect(Gitlab::Git::HooksService).to receive(:new).and_return(service) expect(service).to receive(:execute) - .with(committer, target_repository, old_rev, new_rev, updating_ref) + .with(committer, target_repository.raw_repository, old_rev, new_rev, updating_ref) .and_yield(service).and_return(true) end it 'runs without errors' do expect do - GitOperationService.new(committer, repository).with_branch('feature') do + Gitlab::Git::OperationService.new(committer, repository.raw_repository).with_branch('feature') do new_rev end end.not_to raise_error end it 'ensures the autocrlf Git option is set to :input' do - service = GitOperationService.new(committer, repository) + service = Gitlab::Git::OperationService.new(committer, repository.raw_repository) expect(service).to receive(:update_autocrlf_option) @@ -956,7 +956,7 @@ describe Repository, models: true do it 'updates the head' do expect(repository.find_branch('feature').dereferenced_target.id).to eq(old_rev) - GitOperationService.new(committer, repository).with_branch('feature') do + Gitlab::Git::OperationService.new(committer, repository.raw_repository).with_branch('feature') do new_rev end @@ -971,13 +971,13 @@ describe Repository, models: true do let(:updating_ref) { 'refs/heads/master' } it 'fetch_ref and create the branch' do - expect(target_project.repository).to receive(:fetch_ref) + expect(target_project.repository.raw_repository).to receive(:fetch_ref) .and_call_original - GitOperationService.new(committer, target_repository) + Gitlab::Git::OperationService.new(committer, target_repository.raw_repository) .with_branch( 'master', - start_project: project, + start_repository: project.repository.raw_repository, start_branch_name: 'feature') { new_rev } expect(target_repository.branch_names).to contain_exactly('master') @@ -990,8 +990,8 @@ describe Repository, models: true do it 'does not fetch_ref and just pass the commit' do expect(target_repository).not_to receive(:fetch_ref) - GitOperationService.new(committer, target_repository) - .with_branch('feature', start_project: project) { new_rev } + Gitlab::Git::OperationService.new(committer, target_repository.raw_repository) + .with_branch('feature', start_repository: project.repository.raw_repository) { new_rev } end end end @@ -1000,7 +1000,7 @@ describe Repository, models: true do let(:target_project) { create(:project, :empty_repo) } before do - expect(target_project.repository).to receive(:run_git) + expect(target_project.repository.raw_repository).to receive(:run_git) end it 'raises Rugged::ReferenceError' do @@ -1009,9 +1009,9 @@ describe Repository, models: true do end expect do - GitOperationService.new(committer, target_project.repository) + Gitlab::Git::OperationService.new(committer, target_project.repository.raw_repository) .with_branch('feature', - start_project: project, + start_repository: project.repository.raw_repository, &:itself) end.to raise_reference_error end @@ -1031,7 +1031,7 @@ describe Repository, models: true do repository.add_branch(user, branch, old_rev) expect do - GitOperationService.new(committer, repository).with_branch(branch) do + Gitlab::Git::OperationService.new(committer, repository.raw_repository).with_branch(branch) do new_rev end end.not_to raise_error @@ -1049,10 +1049,10 @@ describe Repository, models: true do # Updating 'master' to new_rev would lose the commits on 'master' that # are not contained in new_rev. This should not be allowed. expect do - GitOperationService.new(committer, repository).with_branch(branch) do + Gitlab::Git::OperationService.new(committer, repository.raw_repository).with_branch(branch) do new_rev end - end.to raise_error(Repository::CommitError) + end.to raise_error(Gitlab::Git::CommitError) end end @@ -1061,7 +1061,7 @@ describe Repository, models: true do allow_any_instance_of(Gitlab::Git::Hook).to receive(:trigger).and_return([false, '']) expect do - GitOperationService.new(committer, repository).with_branch('feature') do + Gitlab::Git::OperationService.new(committer, repository.raw_repository).with_branch('feature') do new_rev end end.to raise_error(Gitlab::Git::HooksService::PreReceiveError) @@ -1079,10 +1079,9 @@ describe Repository, models: true do expect(repository).not_to receive(:expire_emptiness_caches) expect(repository).to receive(:expire_branches_cache) - GitOperationService.new(committer, repository) - .with_branch('new-feature') do - new_rev - end + repository.with_branch(user, 'new-feature') do + new_rev + end end end @@ -1139,7 +1138,7 @@ describe Repository, models: true do describe 'when there are no branches' do before do - allow(repository).to receive(:branch_count).and_return(0) + allow(repository.raw_repository).to receive(:branch_count).and_return(0) end it { is_expected.to eq(false) } @@ -1147,7 +1146,7 @@ describe Repository, models: true do describe 'when there are branches' do it 'returns true' do - expect(repository).to receive(:branch_count).and_return(3) + expect(repository.raw_repository).to receive(:branch_count).and_return(3) expect(subject).to eq(true) end @@ -1161,7 +1160,7 @@ describe Repository, models: true do end it 'sets autocrlf to :input' do - GitOperationService.new(nil, repository).send(:update_autocrlf_option) + Gitlab::Git::OperationService.new(nil, repository.raw_repository).send(:update_autocrlf_option) expect(repository.raw_repository.autocrlf).to eq(:input) end @@ -1176,7 +1175,7 @@ describe Repository, models: true do expect(repository.raw_repository).not_to receive(:autocrlf=) .with(:input) - GitOperationService.new(nil, repository).send(:update_autocrlf_option) + Gitlab::Git::OperationService.new(nil, repository.raw_repository).send(:update_autocrlf_option) end end end @@ -1379,8 +1378,11 @@ describe Repository, models: true do it 'cherry-picks the changes' do expect(repository.blob_at_branch('improve/awesome', 'foo/bar/.gitkeep')).to be_nil - repository.cherry_pick(user, pickable_merge, 'improve/awesome') + cherry_pick_commit_sha = repository.cherry_pick(user, pickable_merge, 'improve/awesome') + cherry_pick_commit_message = project.commit(cherry_pick_commit_sha).message + expect(repository.blob_at_branch('improve/awesome', 'foo/bar/.gitkeep')).not_to be_nil + expect(cherry_pick_commit_message).to include('cherry picked from') end end end @@ -1759,15 +1761,15 @@ describe Repository, models: true do describe '#update_ref' do it 'can create a ref' do - GitOperationService.new(nil, repository).send(:update_ref, 'refs/heads/foobar', 'refs/heads/master', Gitlab::Git::BLANK_SHA) + Gitlab::Git::OperationService.new(nil, repository.raw_repository).send(:update_ref, 'refs/heads/foobar', 'refs/heads/master', Gitlab::Git::BLANK_SHA) expect(repository.find_branch('foobar')).not_to be_nil end it 'raises CommitError when the ref update fails' do expect do - GitOperationService.new(nil, repository).send(:update_ref, 'refs/heads/master', 'refs/heads/master', Gitlab::Git::BLANK_SHA) - end.to raise_error(Repository::CommitError) + Gitlab::Git::OperationService.new(nil, repository.raw_repository).send(:update_ref, 'refs/heads/master', 'refs/heads/master', Gitlab::Git::BLANK_SHA) + end.to raise_error(Gitlab::Git::CommitError) end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index b70ab5581ac..fd83a58ed9f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2102,4 +2102,18 @@ describe User do end end end + + describe '#verified_email?' do + it 'returns true when the email is the primary email' do + user = build :user, email: 'email@example.com' + + expect(user.verified_email?('email@example.com')).to be true + end + + it 'returns false when the email is not the primary email' do + user = build :user, email: 'email@example.com' + + expect(user.verified_email?('other_email@example.com')).to be false + end + end end diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb index 40a222be24d..9ef8d117123 100644 --- a/spec/models/wiki_page_spec.rb +++ b/spec/models/wiki_page_spec.rb @@ -281,6 +281,12 @@ describe WikiPage do @page.title = "Import-existing-repositories-into-GitLab" expect(@page.title).to eq("Import existing repositories into GitLab") end + + it 'unescapes html' do + @page.title = 'foo & bar' + + expect(@page.title).to eq('foo & bar') + end end describe '#directory' do diff --git a/spec/presenters/ci/build_presenter_spec.rb b/spec/presenters/ci/build_presenter_spec.rb index a7a34ecac72..1a8001be6ab 100644 --- a/spec/presenters/ci/build_presenter_spec.rb +++ b/spec/presenters/ci/build_presenter_spec.rb @@ -100,4 +100,38 @@ describe Ci::BuildPresenter do end end end + + describe '#trigger_variables' do + let(:build) { create(:ci_build, pipeline: pipeline, trigger_request: trigger_request) } + let(:trigger) { create(:ci_trigger, project: project) } + let(:trigger_request) { create(:ci_trigger_request, pipeline: pipeline, trigger: trigger) } + + context 'when variable is stored in ci_pipeline_variables' do + let!(:pipeline_variable) { create(:ci_pipeline_variable, pipeline: pipeline) } + + context 'when pipeline is triggered by trigger API' do + it 'returns variables' do + expect(presenter.trigger_variables).to eq([pipeline_variable.to_runner_variable]) + end + end + + context 'when pipeline is not triggered by trigger API' do + let(:build) { create(:ci_build, pipeline: pipeline) } + + it 'does not return variables' do + expect(presenter.trigger_variables).to eq([]) + end + end + end + + context 'when variable is stored in ci_trigger_requests.variables' do + before do + trigger_request.update_attribute(:variables, { 'TRIGGER_KEY_1' => 'TRIGGER_VALUE_1' } ) + end + + it 'returns variables' do + expect(presenter.trigger_variables).to eq(trigger_request.user_variables) + end + end + end end diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb index b1e011de604..cc794fad3a7 100644 --- a/spec/requests/api/branches_spec.rb +++ b/spec/requests/api/branches_spec.rb @@ -75,6 +75,22 @@ describe API::Branches do let(:route) { "/projects/#{project_id}/repository/branches/#{branch_name}" } shared_examples_for 'repository branch' do + context 'HEAD request' do + it 'returns 204 No Content' do + head api(route, user) + + expect(response).to have_gitlab_http_status(204) + expect(response.body).to be_empty + end + + it 'returns 404 Not Found' do + head api("/projects/#{project_id}/repository/branches/unknown", user) + + expect(response).to have_gitlab_http_status(404) + expect(response.body).to be_empty + end + end + it 'returns the repository branch' do get api(route, current_user) diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb index 3c02e6302b4..e4c73583545 100644 --- a/spec/requests/api/commit_statuses_spec.rb +++ b/spec/requests/api/commit_statuses_spec.rb @@ -16,8 +16,8 @@ describe API::CommitStatuses do let(:get_url) { "/projects/#{project.id}/repository/commits/#{sha}/statuses" } context 'ci commit exists' do - let!(:master) { project.pipelines.create(source: :push, sha: commit.id, ref: 'master') } - let!(:develop) { project.pipelines.create(source: :push, sha: commit.id, ref: 'develop') } + let!(:master) { project.pipelines.create(source: :push, sha: commit.id, ref: 'master', protected: false) } + let!(:develop) { project.pipelines.create(source: :push, sha: commit.id, ref: 'develop', protected: false) } context "reporter user" do let(:statuses_id) { json_response.map { |status| status['id'] } } @@ -142,6 +142,9 @@ describe API::CommitStatuses do expect(json_response['ref']).not_to be_empty expect(json_response['target_url']).to be_nil expect(json_response['description']).to be_nil + if status == 'failed' + expect(CommitStatus.find(json_response['id'])).to be_api_failure + end end end end diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index dafe3f466a2..edbfaf510c5 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -565,7 +565,7 @@ describe API::Commits do end context 'when the ref has a pipeline' do - let!(:pipeline) { project.pipelines.create(source: :push, ref: 'master', sha: commit.sha) } + let!(:pipeline) { project.pipelines.create(source: :push, ref: 'master', sha: commit.sha, protected: false) } it 'includes a "created" status' do get api(route, current_user) @@ -804,7 +804,7 @@ describe API::Commits do expect(response).to have_gitlab_http_status(201) expect(response).to match_response_schema('public_api/v4/commit/basic') expect(json_response['title']).to eq(commit.title) - expect(json_response['message']).to eq(commit.message) + expect(json_response['message']).to eq(commit.cherry_pick_message(user)) expect(json_response['author_name']).to eq(commit.author_name) expect(json_response['committer_name']).to eq(user.name) end diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index 971eaf837cb..114019441a3 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -224,7 +224,7 @@ describe API::Files do it "returns a 400 if editor fails to create file" do allow_any_instance_of(Repository).to receive(:create_file) - .and_raise(Repository::CommitError, 'Cannot create file') + .and_raise(Gitlab::Git::CommitError, 'Cannot create file') post api(route("any%2Etxt"), user), valid_params @@ -339,7 +339,7 @@ describe API::Files do end it "returns a 400 if fails to delete file" do - allow_any_instance_of(Repository).to receive(:delete_file).and_raise(Repository::CommitError, 'Cannot delete file') + allow_any_instance_of(Repository).to receive(:delete_file).and_raise(Gitlab::Git::CommitError, 'Cannot delete file') delete api(route(file_path), user), valid_params diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index e9c30dba8d4..a6c804fb2b3 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -660,6 +660,95 @@ describe API::Internal do # end # end + describe 'POST /internal/post_receive' do + let(:gl_repository) { "project-#{project.id}" } + let(:identifier) { 'key-123' } + let(:reference_counter) { double('ReferenceCounter') } + + let(:valid_params) do + { + gl_repository: gl_repository, + secret_token: secret_token, + identifier: identifier, + changes: changes + } + end + + let(:changes) do + "#{Gitlab::Git::BLANK_SHA} 570e7b2abdd848b95f2f578043fc23bd6f6fd24d refs/heads/new_branch" + end + + before do + project.team << [user, :developer] + end + + it 'enqueues a PostReceive worker job' do + expect(PostReceive).to receive(:perform_async) + .with(gl_repository, identifier, changes) + + post api("/internal/post_receive"), valid_params + end + + it 'decreases the reference counter and returns the result' do + expect(Gitlab::ReferenceCounter).to receive(:new).with(gl_repository) + .and_return(reference_counter) + expect(reference_counter).to receive(:decrease).and_return(true) + + post api("/internal/post_receive"), valid_params + + expect(json_response['reference_counter_decreased']).to be(true) + end + + it 'returns link to create new merge request' do + post api("/internal/post_receive"), valid_params + + expect(json_response['merge_request_urls']).to match [{ + "branch_name" => "new_branch", + "url" => "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch", + "new_merge_request" => true + }] + end + + it 'returns empty array if printing_merge_request_link_enabled is false' do + project.update!(printing_merge_request_link_enabled: false) + + post api("/internal/post_receive"), valid_params + + expect(json_response['merge_request_urls']).to eq([]) + end + + context 'broadcast message exists' do + let!(:broadcast_message) { create(:broadcast_message, starts_at: 1.day.ago, ends_at: 1.day.from_now ) } + + it 'returns one broadcast message' do + post api("/internal/post_receive"), valid_params + + expect(response).to have_http_status(200) + expect(json_response['broadcast_message']).to eq(broadcast_message.message) + end + end + + context 'broadcast message does not exist' do + it 'returns empty string' do + post api("/internal/post_receive"), valid_params + + expect(response).to have_http_status(200) + expect(json_response['broadcast_message']).to eq(nil) + end + end + + context 'nil broadcast message' do + it 'returns empty string' do + allow(BroadcastMessage).to receive(:current).and_return(nil) + + post api("/internal/post_receive"), valid_params + + expect(response).to have_http_status(200) + expect(json_response['broadcast_message']).to eq(nil) + end + end + end + def project_with_repo_path(path) double().tap do |fake_project| allow(fake_project).to receive_message_chain('repository.path_to_repo' => path) diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index dee75c96b86..1583d1c2435 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -138,6 +138,16 @@ describe API::Issues, :mailer do expect(first_issue['id']).to eq(issue2.id) end + it 'returns issues reacted by the authenticated user by the given emoji' do + issue2 = create(:issue, project: project, author: user, assignees: [user]) + award_emoji = create(:award_emoji, awardable: issue2, user: user2, name: 'star') + + get api('/issues', user2), my_reaction_emoji: award_emoji.name, scope: 'all' + + expect_paginated_array_response(size: 1) + expect(first_issue['id']).to eq(issue2.id) + end + it 'returns issues matching given search string for title' do get api("/issues", user), search: issue.title diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 9027090aabd..21d2c9644fb 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -117,6 +117,18 @@ describe API::MergeRequests do expect(json_response.length).to eq(1) expect(json_response.first['id']).to eq(merge_request3.id) end + + it 'returns merge requests reacted by the authenticated user by the given emoji' do + merge_request3 = create(:merge_request, :simple, author: user, assignee: user, source_project: project2, target_project: project2, source_branch: 'other-branch') + award_emoji = create(:award_emoji, awardable: merge_request3, user: user2, name: 'star') + + get api('/merge_requests', user2), my_reaction_emoji: award_emoji.name, scope: 'all' + + expect(response).to have_gitlab_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(merge_request3.id) + end end end diff --git a/spec/requests/api/pipeline_schedules_spec.rb b/spec/requests/api/pipeline_schedules_spec.rb index b6a5a7ffbb5..f650df57383 100644 --- a/spec/requests/api/pipeline_schedules_spec.rb +++ b/spec/requests/api/pipeline_schedules_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe API::PipelineSchedules do set(:developer) { create(:user) } set(:user) { create(:user) } - set(:project) { create(:project, :repository) } + set(:project) { create(:project, :repository, public_builds: false) } before do project.add_developer(developer) @@ -110,6 +110,18 @@ describe API::PipelineSchedules do end end + context 'authenticated user with insufficient permissions' do + before do + project.add_guest(user) + end + + it 'does not return pipeline_schedules list' do + get api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", user) + + expect(response).to have_http_status(:not_found) + end + end + context 'unauthenticated user' do it 'does not return pipeline_schedules list' do get api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}") @@ -299,4 +311,150 @@ describe API::PipelineSchedules do end end end + + describe 'POST /projects/:id/pipeline_schedules/:pipeline_schedule_id/variables' do + let(:params) { attributes_for(:ci_pipeline_schedule_variable) } + + set(:pipeline_schedule) do + create(:ci_pipeline_schedule, project: project, owner: developer) + end + + context 'authenticated user with valid permissions' do + context 'with required parameters' do + it 'creates pipeline_schedule_variable' do + expect do + post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables", developer), + params + end.to change { pipeline_schedule.variables.count }.by(1) + + expect(response).to have_http_status(:created) + expect(response).to match_response_schema('pipeline_schedule_variable') + expect(json_response['key']).to eq(params[:key]) + expect(json_response['value']).to eq(params[:value]) + end + end + + context 'without required parameters' do + it 'does not create pipeline_schedule_variable' do + post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables", developer) + + expect(response).to have_http_status(:bad_request) + end + end + + context 'when key has validation error' do + it 'does not create pipeline_schedule_variable' do + post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables", developer), + params.merge('key' => '!?!?') + + expect(response).to have_http_status(:bad_request) + expect(json_response['message']).to have_key('key') + end + end + end + + context 'authenticated user with invalid permissions' do + it 'does not create pipeline_schedule_variable' do + post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables", user), params + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not create pipeline_schedule_variable' do + post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables"), params + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PUT /projects/:id/pipeline_schedules/:pipeline_schedule_id/variables/:key' do + set(:pipeline_schedule) do + create(:ci_pipeline_schedule, project: project, owner: developer) + end + + let(:pipeline_schedule_variable) do + create(:ci_pipeline_schedule_variable, pipeline_schedule: pipeline_schedule) + end + + context 'authenticated user with valid permissions' do + it 'updates pipeline_schedule_variable' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables/#{pipeline_schedule_variable.key}", developer), + value: 'updated_value' + + expect(response).to have_http_status(:ok) + expect(response).to match_response_schema('pipeline_schedule_variable') + expect(json_response['value']).to eq('updated_value') + end + end + + context 'authenticated user with invalid permissions' do + it 'does not update pipeline_schedule_variable' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables/#{pipeline_schedule_variable.key}", user) + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not update pipeline_schedule_variable' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables/#{pipeline_schedule_variable.key}") + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'DELETE /projects/:id/pipeline_schedules/:pipeline_schedule_id/variables/:key' do + let(:master) { create(:user) } + + set(:pipeline_schedule) do + create(:ci_pipeline_schedule, project: project, owner: developer) + end + + let!(:pipeline_schedule_variable) do + create(:ci_pipeline_schedule_variable, pipeline_schedule: pipeline_schedule) + end + + before do + project.add_master(master) + end + + context 'authenticated user with valid permissions' do + it 'deletes pipeline_schedule_variable' do + expect do + delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables/#{pipeline_schedule_variable.key}", master) + end.to change { Ci::PipelineScheduleVariable.count }.by(-1) + + expect(response).to have_http_status(:accepted) + expect(response).to match_response_schema('pipeline_schedule_variable') + end + + it 'responds with 404 Not Found if requesting non-existing pipeline_schedule_variable' do + delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables/____", master) + + expect(response).to have_http_status(:not_found) + end + end + + context 'authenticated user with invalid permissions' do + let!(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: master) } + + it 'does not delete pipeline_schedule_variable' do + delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables/#{pipeline_schedule_variable.key}", developer) + + expect(response).to have_http_status(:forbidden) + end + end + + context 'unauthenticated user' do + it 'does not delete pipeline_schedule_variable' do + delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/variables/#{pipeline_schedule_variable.key}") + + expect(response).to have_http_status(:unauthorized) + end + end + end end diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index 993164aa8fe..12720355a6d 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -557,17 +557,36 @@ describe API::Runner do { 'key' => 'TRIGGER_KEY_1', 'value' => 'TRIGGER_VALUE_1', 'public' => false }] end + let(:trigger) { create(:ci_trigger, project: project) } + let!(:trigger_request) { create(:ci_trigger_request, pipeline: pipeline, builds: [job], trigger: trigger) } + before do - trigger = create(:ci_trigger, project: project) - create(:ci_trigger_request_with_variables, pipeline: pipeline, builds: [job], trigger: trigger) project.variables << Ci::Variable.new(key: 'SECRET_KEY', value: 'secret_value') end - it 'returns variables for triggers' do - request_job + shared_examples 'expected variables behavior' do + it 'returns variables for triggers' do + request_job - expect(response).to have_http_status(201) - expect(json_response['variables']).to include(*expected_variables) + expect(response).to have_http_status(201) + expect(json_response['variables']).to include(*expected_variables) + end + end + + context 'when variables are stored in trigger_request' do + before do + trigger_request.update_attribute(:variables, { TRIGGER_KEY_1: 'TRIGGER_VALUE_1' } ) + end + + it_behaves_like 'expected variables behavior' + end + + context 'when variables are stored in pipeline_variables' do + before do + create(:ci_pipeline_variable, pipeline: pipeline, key: :TRIGGER_KEY_1, value: 'TRIGGER_VALUE_1') + end + + it_behaves_like 'expected variables behavior' end end @@ -626,13 +645,34 @@ describe API::Runner do it 'mark job as succeeded' do update_job(state: 'success') - expect(job.reload.status).to eq 'success' + job.reload + expect(job).to be_success end it 'mark job as failed' do update_job(state: 'failed') - expect(job.reload.status).to eq 'failed' + job.reload + expect(job).to be_failed + expect(job).to be_unknown_failure + end + + context 'when failure_reason is script_failure' do + before do + update_job(state: 'failed', failure_reason: 'script_failure') + job.reload + end + + it { expect(job).to be_script_failure } + end + + context 'when failure_reason is runner_system_failure' do + before do + update_job(state: 'failed', failure_reason: 'runner_system_failure') + job.reload + end + + it { expect(job).to be_runner_system_failure } end end diff --git a/spec/requests/api/runners_spec.rb b/spec/requests/api/runners_spec.rb index 244895a417e..67907579225 100644 --- a/spec/requests/api/runners_spec.rb +++ b/spec/requests/api/runners_spec.rb @@ -191,7 +191,8 @@ describe API::Runners do active: !active, tag_list: ['ruby2.1', 'pgsql', 'mysql'], run_untagged: 'false', - locked: 'true') + locked: 'true', + access_level: 'ref_protected') shared_runner.reload expect(response).to have_http_status(200) @@ -200,6 +201,7 @@ describe API::Runners do expect(shared_runner.tag_list).to include('ruby2.1', 'pgsql', 'mysql') expect(shared_runner.run_untagged?).to be(false) expect(shared_runner.locked?).to be(true) + expect(shared_runner.ref_protected?).to be_truthy expect(shared_runner.ensure_runner_queue_value) .not_to eq(runner_queue_value) end diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index 737c028ad53..0b9a4b5c3db 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -19,6 +19,10 @@ describe API::Settings, 'Settings' do expect(json_response['default_project_visibility']).to be_a String expect(json_response['default_snippet_visibility']).to be_a String expect(json_response['default_group_visibility']).to be_a String + expect(json_response['rsa_key_restriction']).to eq(0) + expect(json_response['dsa_key_restriction']).to eq(0) + expect(json_response['ecdsa_key_restriction']).to eq(0) + expect(json_response['ed25519_key_restriction']).to eq(0) end end @@ -44,7 +48,11 @@ describe API::Settings, 'Settings' do help_page_text: 'custom help text', help_page_hide_commercial_content: true, help_page_support_url: 'http://example.com/help', - project_export_enabled: false + project_export_enabled: false, + rsa_key_restriction: ApplicationSetting::FORBIDDEN_KEY_VALUE, + dsa_key_restriction: 2048, + ecdsa_key_restriction: 384, + ed25519_key_restriction: 256 expect(response).to have_http_status(200) expect(json_response['default_projects_limit']).to eq(3) @@ -61,6 +69,10 @@ describe API::Settings, 'Settings' do expect(json_response['help_page_hide_commercial_content']).to be_truthy expect(json_response['help_page_support_url']).to eq('http://example.com/help') expect(json_response['project_export_enabled']).to be_falsey + expect(json_response['rsa_key_restriction']).to eq(ApplicationSetting::FORBIDDEN_KEY_VALUE) + expect(json_response['dsa_key_restriction']).to eq(2048) + expect(json_response['ecdsa_key_restriction']).to eq(384) + expect(json_response['ed25519_key_restriction']).to eq(256) end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 5fef4437997..37cb95a16e3 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -4,6 +4,7 @@ describe API::Users do let(:user) { create(:user) } let(:admin) { create(:admin) } let(:key) { create(:key, user: user) } + let(:gpg_key) { create(:gpg_key, user: user) } let(:email) { create(:email, user: user) } let(:omniauth_user) { create(:omniauth_user) } let(:ldap_user) { create(:omniauth_user, provider: 'ldapmain') } @@ -753,6 +754,164 @@ describe API::Users do end end + describe 'POST /users/:id/keys' do + before do + admin + end + + it 'does not create invalid GPG key' do + post api("/users/#{user.id}/gpg_keys", admin) + + expect(response).to have_http_status(400) + expect(json_response['error']).to eq('key is missing') + end + + it 'creates GPG key' do + key_attrs = attributes_for :gpg_key + expect do + post api("/users/#{user.id}/gpg_keys", admin), key_attrs + + expect(response).to have_http_status(201) + end.to change { user.gpg_keys.count }.by(1) + end + + it 'returns 400 for invalid ID' do + post api('/users/999999/gpg_keys', admin) + + expect(response).to have_http_status(400) + end + end + + describe 'GET /user/:id/gpg_keys' do + before do + admin + end + + context 'when unauthenticated' do + it 'returns authentication error' do + get api("/users/#{user.id}/gpg_keys") + + expect(response).to have_http_status(401) + end + end + + context 'when authenticated' do + it 'returns 404 for non-existing user' do + get api('/users/999999/gpg_keys', admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') + end + + it 'returns 404 error if key not foud' do + delete api("/users/#{user.id}/gpg_keys/42", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 GPG Key Not Found') + end + + it 'returns array of GPG keys' do + user.gpg_keys << gpg_key + user.save + + get api("/users/#{user.id}/gpg_keys", admin) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.first['key']).to eq(gpg_key.key) + end + end + end + + describe 'DELETE /user/:id/gpg_keys/:key_id' do + before do + admin + end + + context 'when unauthenticated' do + it 'returns authentication error' do + delete api("/users/#{user.id}/keys/42") + + expect(response).to have_http_status(401) + end + end + + context 'when authenticated' do + it 'deletes existing key' do + user.gpg_keys << gpg_key + user.save + + expect do + delete api("/users/#{user.id}/gpg_keys/#{gpg_key.id}", admin) + + expect(response).to have_http_status(204) + end.to change { user.gpg_keys.count }.by(-1) + end + + it 'returns 404 error if user not found' do + user.keys << key + user.save + + delete api("/users/999999/gpg_keys/#{gpg_key.id}", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') + end + + it 'returns 404 error if key not foud' do + delete api("/users/#{user.id}/gpg_keys/42", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 GPG Key Not Found') + end + end + end + + describe 'POST /user/:id/gpg_keys/:key_id/revoke' do + before do + admin + end + + context 'when unauthenticated' do + it 'returns authentication error' do + post api("/users/#{user.id}/gpg_keys/42/revoke") + + expect(response).to have_http_status(401) + end + end + + context 'when authenticated' do + it 'revokes existing key' do + user.gpg_keys << gpg_key + user.save + + expect do + post api("/users/#{user.id}/gpg_keys/#{gpg_key.id}/revoke", admin) + + expect(response).to have_http_status(:accepted) + end.to change { user.gpg_keys.count }.by(-1) + end + + it 'returns 404 error if user not found' do + user.gpg_keys << gpg_key + user.save + + post api("/users/999999/gpg_keys/#{gpg_key.id}/revoke", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') + end + + it 'returns 404 error if key not foud' do + post api("/users/#{user.id}/gpg_keys/42/revoke", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 GPG Key Not Found') + end + end + end + describe "POST /users/:id/emails" do before do admin @@ -1153,6 +1312,173 @@ describe API::Users do end end + describe 'GET /user/gpg_keys' do + context 'when unauthenticated' do + it 'returns authentication error' do + get api('/user/gpg_keys') + + expect(response).to have_http_status(401) + end + end + + context 'when authenticated' do + it 'returns array of GPG keys' do + user.gpg_keys << gpg_key + user.save + + get api('/user/gpg_keys', user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.first['key']).to eq(gpg_key.key) + end + + context 'scopes' do + let(:path) { '/user/gpg_keys' } + let(:api_call) { method(:api) } + + include_examples 'allows the "read_user" scope' + end + end + end + + describe 'GET /user/gpg_keys/:key_id' do + it 'returns a single key' do + user.gpg_keys << gpg_key + user.save + + get api("/user/gpg_keys/#{gpg_key.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['key']).to eq(gpg_key.key) + end + + it 'returns 404 Not Found within invalid ID' do + get api('/user/gpg_keys/42', user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 GPG Key Not Found') + end + + it "returns 404 error if admin accesses user's GPG key" do + user.gpg_keys << gpg_key + user.save + + get api("/user/gpg_keys/#{gpg_key.id}", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 GPG Key Not Found') + end + + it 'returns 404 for invalid ID' do + get api('/users/gpg_keys/ASDF', admin) + + expect(response).to have_http_status(404) + end + + context 'scopes' do + let(:path) { "/user/gpg_keys/#{gpg_key.id}" } + let(:api_call) { method(:api) } + + include_examples 'allows the "read_user" scope' + end + end + + describe 'POST /user/gpg_keys' do + it 'creates a GPG key' do + key_attrs = attributes_for :gpg_key + expect do + post api('/user/gpg_keys', user), key_attrs + + expect(response).to have_http_status(201) + end.to change { user.gpg_keys.count }.by(1) + end + + it 'returns a 401 error if unauthorized' do + post api('/user/gpg_keys'), key: 'some key' + + expect(response).to have_http_status(401) + end + + it 'does not create GPG key without key' do + post api('/user/gpg_keys', user) + + expect(response).to have_http_status(400) + expect(json_response['error']).to eq('key is missing') + end + end + + describe 'POST /user/gpg_keys/:key_id/revoke' do + it 'revokes existing GPG key' do + user.gpg_keys << gpg_key + user.save + + expect do + post api("/user/gpg_keys/#{gpg_key.id}/revoke", user) + + expect(response).to have_http_status(:accepted) + end.to change { user.gpg_keys.count}.by(-1) + end + + it 'returns 404 if key ID not found' do + post api('/user/gpg_keys/42/revoke', user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 GPG Key Not Found') + end + + it 'returns 401 error if unauthorized' do + user.gpg_keys << gpg_key + user.save + + post api("/user/gpg_keys/#{gpg_key.id}/revoke") + + expect(response).to have_http_status(401) + end + + it 'returns a 404 for invalid ID' do + post api('/users/gpg_keys/ASDF/revoke', admin) + + expect(response).to have_http_status(404) + end + end + + describe 'DELETE /user/gpg_keys/:key_id' do + it 'deletes existing GPG key' do + user.gpg_keys << gpg_key + user.save + + expect do + delete api("/user/gpg_keys/#{gpg_key.id}", user) + + expect(response).to have_http_status(204) + end.to change { user.gpg_keys.count}.by(-1) + end + + it 'returns 404 if key ID not found' do + delete api('/user/gpg_keys/42', user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 GPG Key Not Found') + end + + it 'returns 401 error if unauthorized' do + user.gpg_keys << gpg_key + user.save + + delete api("/user/gpg_keys/#{gpg_key.id}") + + expect(response).to have_http_status(401) + end + + it 'returns a 404 for invalid ID' do + delete api('/users/gpg_keys/ASDF', admin) + + expect(response).to have_http_status(404) + end + end + describe "GET /user/emails" do context "when unauthenticated" do it "returns authentication error" do diff --git a/spec/requests/api/v3/commits_spec.rb b/spec/requests/api/v3/commits_spec.rb index 4a4a5dc5c7c..6d0ca33a6fa 100644 --- a/spec/requests/api/v3/commits_spec.rb +++ b/spec/requests/api/v3/commits_spec.rb @@ -386,7 +386,7 @@ describe API::V3::Commits do end it "returns status for CI" do - pipeline = project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha) + pipeline = project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha, protected: false) pipeline.update(status: 'success') get v3_api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) @@ -396,7 +396,7 @@ describe API::V3::Commits do end it "returns status for CI when pipeline is created" do - project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha) + project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha, protected: false) get v3_api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) @@ -474,7 +474,7 @@ describe API::V3::Commits do expect(response).to have_http_status(201) expect(json_response['title']).to eq(master_pickable_commit.title) - expect(json_response['message']).to eq(master_pickable_commit.message) + expect(json_response['message']).to eq(master_pickable_commit.cherry_pick_message(user)) expect(json_response['author_name']).to eq(master_pickable_commit.author_name) expect(json_response['committer_name']).to eq(user.name) end diff --git a/spec/requests/api/v3/files_spec.rb b/spec/requests/api/v3/files_spec.rb index 4ffa5d1784e..dc7f0eefd16 100644 --- a/spec/requests/api/v3/files_spec.rb +++ b/spec/requests/api/v3/files_spec.rb @@ -127,7 +127,7 @@ describe API::V3::Files do it "returns a 400 if editor fails to create file" do allow_any_instance_of(Repository).to receive(:create_file) - .and_raise(Repository::CommitError, 'Cannot create file') + .and_raise(Gitlab::Git::CommitError, 'Cannot create file') post v3_api("/projects/#{project.id}/repository/files", user), valid_params @@ -228,7 +228,7 @@ describe API::V3::Files do end it "returns a 400 if fails to delete file" do - allow_any_instance_of(Repository).to receive(:delete_file).and_raise(Repository::CommitError, 'Cannot delete file') + allow_any_instance_of(Repository).to receive(:delete_file).and_raise(Gitlab::Git::CommitError, 'Cannot delete file') delete v3_api("/projects/#{project.id}/repository/files", user), valid_params diff --git a/spec/requests/api/v3/triggers_spec.rb b/spec/requests/api/v3/triggers_spec.rb index d4648136841..7ccf387f2dc 100644 --- a/spec/requests/api/v3/triggers_spec.rb +++ b/spec/requests/api/v3/triggers_spec.rb @@ -37,7 +37,7 @@ describe API::V3::Triggers do it 'returns unauthorized if token is for different project' do post v3_api("/projects/#{project2.id}/trigger/builds"), options.merge(ref: 'master') - expect(response).to have_http_status(401) + expect(response).to have_http_status(404) end end @@ -80,7 +80,8 @@ describe API::V3::Triggers do post v3_api("/projects/#{project.id}/trigger/builds"), options.merge(variables: variables, ref: 'master') expect(response).to have_http_status(201) pipeline.builds.reload - expect(pipeline.builds.first.trigger_request.variables).to eq(variables) + expect(pipeline.variables.map { |v| { v.key => v.value } }.first).to eq(variables) + expect(json_response['variables']).to eq(variables) end end end diff --git a/spec/serializers/note_entity_spec.rb b/spec/serializers/note_entity_spec.rb new file mode 100644 index 00000000000..3459cc72063 --- /dev/null +++ b/spec/serializers/note_entity_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe NoteEntity do + include Gitlab::Routing + + let(:request) { double('request', current_user: user, noteable: note.noteable) } + + let(:entity) { described_class.new(note, request: request) } + let(:note) { create(:note) } + let(:user) { create(:user) } + subject { entity.as_json } + + context 'basic note' do + it 'exposes correct elements' do + expect(subject).to include(:type, :author, :human_access, :note, :note_html, :current_user, + :discussion_id, :emoji_awardable, :award_emoji, :toggle_award_path, :report_abuse_path, :path, :attachment) + end + + it 'does not expose elements for specific notes cases' do + expect(subject).not_to include(:last_edited_by, :last_edited_at, :system_note_icon_name) + end + + it 'exposes author correctly' do + expect(subject[:author]).to include(:id, :name, :username, :state, :avatar_url, :path) + end + + it 'does not expose web_url for author' do + expect(subject[:author]).not_to include(:web_url) + end + end + + context 'when note was edited' do + before do + note.update(updated_at: 1.minute.from_now, updated_by: user) + end + + it 'exposes last_edited_at and last_edited_by elements' do + expect(subject).to include(:last_edited_at, :last_edited_by) + end + end + + context 'when note is a system note' do + before do + note.update(system: true) + end + + it 'exposes system_note_icon_name element' do + expect(subject).to include(:system_note_icon_name) + end + end +end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index 4ba3dada37c..49d7c663128 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -391,12 +391,15 @@ describe Ci::CreatePipelineService do end context 'when user is master' do + let(:pipeline) { execute_service } + before do project.add_master(user) end - it 'creates a pipeline' do - expect(execute_service).to be_persisted + it 'creates a protected pipeline' do + expect(pipeline).to be_persisted + expect(pipeline).to be_protected expect(Ci::Pipeline.count).to eq(1) end end @@ -468,10 +471,11 @@ describe Ci::CreatePipelineService do let(:user) {} let(:trigger) { create(:ci_trigger, owner: nil) } let(:trigger_request) { create(:ci_trigger_request, trigger: trigger) } + let(:pipeline) { execute_service(trigger_request: trigger_request) } - it 'creates a pipeline' do - expect(execute_service(trigger_request: trigger_request)) - .to be_persisted + it 'creates an unprotected pipeline' do + expect(pipeline).to be_persisted + expect(pipeline).not_to be_protected expect(Ci::Pipeline.count).to eq(1) end end diff --git a/spec/services/ci/create_trigger_request_service_spec.rb b/spec/services/ci/create_trigger_request_service_spec.rb deleted file mode 100644 index 8295813a1ca..00000000000 --- a/spec/services/ci/create_trigger_request_service_spec.rb +++ /dev/null @@ -1,52 +0,0 @@ -require 'spec_helper' - -describe Ci::CreateTriggerRequestService do - let(:service) { described_class } - let(:project) { create(:project, :repository) } - let(:trigger) { create(:ci_trigger, project: project, owner: owner) } - let(:owner) { create(:user) } - - before do - stub_ci_pipeline_to_return_yaml_file - - project.add_developer(owner) - end - - describe '#execute' do - context 'valid params' do - subject { service.execute(project, trigger, 'master') } - - context 'without owner' do - it { expect(subject.trigger_request).to be_kind_of(Ci::TriggerRequest) } - it { expect(subject.trigger_request.builds.first).to be_kind_of(Ci::Build) } - it { expect(subject.pipeline).to be_kind_of(Ci::Pipeline) } - it { expect(subject.pipeline).to be_trigger } - end - - context 'with owner' do - it { expect(subject.trigger_request).to be_kind_of(Ci::TriggerRequest) } - it { expect(subject.trigger_request.builds.first).to be_kind_of(Ci::Build) } - it { expect(subject.trigger_request.builds.first.user).to eq(owner) } - it { expect(subject.pipeline).to be_kind_of(Ci::Pipeline) } - it { expect(subject.pipeline).to be_trigger } - it { expect(subject.pipeline.user).to eq(owner) } - end - end - - context 'no commit for ref' do - subject { service.execute(project, trigger, 'other-branch') } - - it { expect(subject.pipeline).not_to be_persisted } - end - - context 'no builds created' do - subject { service.execute(project, trigger, 'master') } - - before do - stub_ci_pipeline_yaml_file('script: { only: [develop], script: hello World }') - end - - it { expect(subject.pipeline).not_to be_persisted } - end - end -end diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index 8eb0d2d10a4..5ac30111ec9 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -4,7 +4,7 @@ module Ci describe RegisterJobService do let!(:project) { FactoryGirl.create :project, shared_runners_enabled: false } let!(:pipeline) { FactoryGirl.create :ci_pipeline, project: project } - let!(:pending_build) { FactoryGirl.create :ci_build, pipeline: pipeline } + let!(:pending_job) { FactoryGirl.create :ci_build, pipeline: pipeline } let!(:shared_runner) { FactoryGirl.create(:ci_runner, is_shared: true) } let!(:specific_runner) { FactoryGirl.create(:ci_runner, is_shared: false) } @@ -15,32 +15,32 @@ module Ci describe '#execute' do context 'runner follow tag list' do it "picks build with the same tag" do - pending_build.tag_list = ["linux"] - pending_build.save + pending_job.tag_list = ["linux"] + pending_job.save specific_runner.tag_list = ["linux"] - expect(execute(specific_runner)).to eq(pending_build) + expect(execute(specific_runner)).to eq(pending_job) end it "does not pick build with different tag" do - pending_build.tag_list = ["linux"] - pending_build.save + pending_job.tag_list = ["linux"] + pending_job.save specific_runner.tag_list = ["win32"] expect(execute(specific_runner)).to be_falsey end it "picks build without tag" do - expect(execute(specific_runner)).to eq(pending_build) + expect(execute(specific_runner)).to eq(pending_job) end it "does not pick build with tag" do - pending_build.tag_list = ["linux"] - pending_build.save + pending_job.tag_list = ["linux"] + pending_job.save expect(execute(specific_runner)).to be_falsey end it "pick build without tag" do specific_runner.tag_list = ["win32"] - expect(execute(specific_runner)).to eq(pending_build) + expect(execute(specific_runner)).to eq(pending_job) end end @@ -76,7 +76,7 @@ module Ci let!(:pipeline2) { create :ci_pipeline, project: project2 } let!(:project3) { create :project, shared_runners_enabled: true } let!(:pipeline3) { create :ci_pipeline, project: project3 } - let!(:build1_project1) { pending_build } + let!(:build1_project1) { pending_job } let!(:build2_project1) { FactoryGirl.create :ci_build, pipeline: pipeline } let!(:build3_project1) { FactoryGirl.create :ci_build, pipeline: pipeline } let!(:build1_project2) { FactoryGirl.create :ci_build, pipeline: pipeline2 } @@ -172,7 +172,7 @@ module Ci context 'when first build is stalled' do before do - pending_build.lock_version = 10 + pending_job.lock_version = 10 end subject { described_class.new(specific_runner).execute } @@ -182,7 +182,7 @@ module Ci before do allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner) - .and_return([pending_build, other_build]) + .and_return([pending_job, other_build]) end it "receives second build from the queue" do @@ -194,7 +194,7 @@ module Ci context 'when single build is in queue' do before do allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner) - .and_return([pending_build]) + .and_return([pending_job]) end it "does not receive any valid result" do @@ -215,6 +215,70 @@ module Ci end end + context 'when access_level of runner is not_protected' do + let!(:specific_runner) { create(:ci_runner, :specific) } + + context 'when a job is protected' do + let!(:pending_job) { create(:ci_build, :protected, pipeline: pipeline) } + + it 'picks the job' do + expect(execute(specific_runner)).to eq(pending_job) + end + end + + context 'when a job is unprotected' do + let!(:pending_job) { create(:ci_build, pipeline: pipeline) } + + it 'picks the job' do + expect(execute(specific_runner)).to eq(pending_job) + end + end + + context 'when protected attribute of a job is nil' do + let!(:pending_job) { create(:ci_build, pipeline: pipeline) } + + before do + pending_job.update_attribute(:protected, nil) + end + + it 'picks the job' do + expect(execute(specific_runner)).to eq(pending_job) + end + end + end + + context 'when access_level of runner is ref_protected' do + let!(:specific_runner) { create(:ci_runner, :ref_protected, :specific) } + + context 'when a job is protected' do + let!(:pending_job) { create(:ci_build, :protected, pipeline: pipeline) } + + it 'picks the job' do + expect(execute(specific_runner)).to eq(pending_job) + end + end + + context 'when a job is unprotected' do + let!(:pending_job) { create(:ci_build, pipeline: pipeline) } + + it 'does not pick the job' do + expect(execute(specific_runner)).to be_nil + end + end + + context 'when protected attribute of a job is nil' do + let!(:pending_job) { create(:ci_build, pipeline: pipeline) } + + before do + pending_job.update_attribute(:protected, nil) + end + + it 'does not pick the job' do + expect(execute(specific_runner)).to be_nil + end + end + end + def execute(runner) described_class.new(runner).execute.build end diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index cec667071cc..f5ed9ff608f 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 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 retried].freeze + user_id auto_canceled_by_id retried failure_reason].freeze shared_examples 'build duplication' do let(:stage) do @@ -48,7 +48,7 @@ describe Ci::RetryBuildService do describe 'clone accessors' do CLONE_ACCESSORS.each do |attribute| it "clones #{attribute} build attribute" do - expect(new_build.send(attribute)).to be_present + expect(new_build.send(attribute)).not_to be_nil expect(new_build.send(attribute)).to eq build.send(attribute) end end diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index 34fb16edc84..85f46838351 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -510,6 +510,26 @@ describe Issues::UpdateService, :mailer do end end + context 'move issue to another project' do + let(:target_project) { create(:project) } + + context 'valid project' do + before do + target_project.team << [user, :master] + end + + it 'calls the move service with the proper issue and project' do + move_stub = instance_double(Issues::MoveService) + allow(Issues::MoveService).to receive(:new).and_return(move_stub) + allow(move_stub).to receive(:execute).with(issue, target_project).and_return(issue) + + expect(move_stub).to receive(:execute).with(issue, target_project) + + update_issue(target_project: target_project) + end + end + end + include_examples 'issuable update service' do let(:open_issuable) { issue } let(:closed_issuable) { create(:closed_issue, project: project) } diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index 088b7b4fc04..5da634e2fb1 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -1,11 +1,12 @@ require 'spec_helper' describe Projects::CreateService, '#execute' do + let(:gitlab_shell) { Gitlab::Shell.new } let(:user) { create :user } let(:opts) do { - name: "GitLab", - namespace: user.namespace + name: 'GitLab', + namespace_id: user.namespace.id } end @@ -146,6 +147,41 @@ describe Projects::CreateService, '#execute' do expect(project.owner).to eq(user) expect(project.namespace).to eq(user.namespace) end + + context 'when another repository already exists on disk' do + let(:opts) do + { + name: 'Existing', + namespace_id: user.namespace.id + } + end + + let(:repository_storage_path) { Gitlab.config.repositories.storages['default']['path'] } + + before do + gitlab_shell.add_repository(repository_storage_path, "#{user.namespace.full_path}/existing") + end + + after do + gitlab_shell.remove_repository(repository_storage_path, "#{user.namespace.full_path}/existing") + end + + it 'does not allow to create project with same path' do + project = create_project(user, opts) + + expect(project).to respond_to(:errors) + expect(project.errors.messages).to have_key(:base) + expect(project.errors.messages[:base].first).to match('There is already a repository with that name on disk') + end + + it 'does not allow to import a project with the same path' do + project = create_project(user, opts.merge({ import_url: 'https://gitlab.com/gitlab-org/gitlab-test.git' })) + + expect(project).to respond_to(:errors) + expect(project.errors.messages).to have_key(:base) + expect(project.errors.messages[:base].first).to match('There is already a repository with that name on disk') + end + end end context 'when there is an active service template' do diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index 21c4b30734c..a6e0364d44c 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe Projects::ForkService do + let(:gitlab_shell) { Gitlab::Shell.new } + describe 'fork by user' do before do @from_user = create(:user) @@ -73,6 +75,26 @@ describe Projects::ForkService do end end + context 'repository already exists' do + let(:repository_storage_path) { Gitlab.config.repositories.storages['default']['path'] } + + before do + gitlab_shell.add_repository(repository_storage_path, "#{@to_user.namespace.full_path}/#{@from_project.path}") + end + + after do + gitlab_shell.remove_repository(repository_storage_path, "#{@to_user.namespace.full_path}/#{@from_project.path}") + end + + it 'does not allow creation' do + to_project = fork_project(@from_project, @to_user) + + expect(to_project).not_to be_persisted + expect(to_project.errors.messages).to have_key(:base) + expect(to_project.errors.messages[:base].first).to match('There is already a repository with that name on disk') + end + end + context 'GitLab CI is enabled' do it "forks and enables CI for fork" do @from_project.enable_ci diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb index 2cb60cbcfc4..a14ed526f68 100644 --- a/spec/services/projects/transfer_service_spec.rb +++ b/spec/services/projects/transfer_service_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe Projects::TransferService do + let(:gitlab_shell) { Gitlab::Shell.new } let(:user) { create(:user) } let(:group) { create(:group) } let(:project) { create(:project, :repository, namespace: user.namespace) } @@ -119,6 +120,25 @@ describe Projects::TransferService do it { expect(project.namespace).to eq(user.namespace) } end + context 'namespace which contains orphan repository with same projects path name' do + let(:repository_storage_path) { Gitlab.config.repositories.storages['default']['path'] } + + before do + group.add_owner(user) + gitlab_shell.add_repository(repository_storage_path, "#{group.full_path}/#{project.path}") + + @result = transfer_project(project, user, group) + end + + after do + gitlab_shell.remove_repository(repository_storage_path, "#{group.full_path}/#{project.path}") + end + + it { expect(@result).to eq false } + it { expect(project.namespace).to eq(user.namespace) } + it { expect(project.errors[:new_namespace]).to include('Cannot move project') } + end + def transfer_project(project, user, new_namespace) service = Projects::TransferService.new(project, user) diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb index aa6ad6340f5..031366d1825 100644 --- a/spec/services/projects/update_pages_service_spec.rb +++ b/spec/services/projects/update_pages_service_spec.rb @@ -116,6 +116,7 @@ describe Projects::UpdatePagesService do expect(deploy_status.description) .to match(/artifacts for pages are too large/) + expect(deploy_status).to be_script_failure end end diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index 1b282e82187..92cc9a37795 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe Projects::UpdateService, '#execute' do + let(:gitlab_shell) { Gitlab::Shell.new } let(:user) { create(:user) } let(:admin) { create(:admin) } @@ -132,6 +133,28 @@ describe Projects::UpdateService, '#execute' do end end + context 'when renaming a project' do + let(:repository_storage_path) { Gitlab.config.repositories.storages['default']['path'] } + + before do + gitlab_shell.add_repository(repository_storage_path, "#{user.namespace.full_path}/existing") + end + + after do + gitlab_shell.remove_repository(repository_storage_path, "#{user.namespace.full_path}/existing") + end + + it 'does not allow renaming when new path matches existing repository on disk' do + result = update_project(project, admin, path: 'existing') + + expect(result).to include(status: :error) + expect(result[:message]).to match('Project could not be updated!') + expect(project).not_to be_valid + expect(project.errors.messages).to have_key(:base) + expect(project.errors.messages[:base]).to include('There is already a repository with that name on disk') + end + end + context 'when passing invalid parameters' do it 'returns an error result when record cannot be updated' do result = update_project(project, admin, { name: 'foo&bar' }) diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb index 30fa0ee6873..6926ac85de3 100644 --- a/spec/services/quick_actions/interpret_service_spec.rb +++ b/spec/services/quick_actions/interpret_service_spec.rb @@ -1147,5 +1147,15 @@ describe QuickActions::InterpretService do expect(explanations).to eq(["Moves issue to ~#{bug.id} column in the board."]) end end + + describe 'move issue to another project command' do + let(:content) { '/move test/project' } + + it 'includes the project name' do + _, explanations = service.explain(content, issue) + + expect(explanations).to eq(["Moves this issue to test/project."]) + end + end end end diff --git a/spec/support/cycle_analytics_helpers.rb b/spec/support/cycle_analytics_helpers.rb index 39586d37e93..934b4557ba2 100644 --- a/spec/support/cycle_analytics_helpers.rb +++ b/spec/support/cycle_analytics_helpers.rb @@ -80,7 +80,8 @@ module CycleAnalyticsHelpers sha: project.repository.commit('master').sha, ref: 'master', source: :push, - project: project) + project: project, + protected: false) end def new_dummy_job(environment) @@ -93,7 +94,8 @@ module CycleAnalyticsHelpers ref: 'master', tag: false, name: 'dummy', - pipeline: dummy_pipeline) + pipeline: dummy_pipeline, + protected: false) end end diff --git a/spec/support/features/discussion_comments_shared_example.rb b/spec/support/features/discussion_comments_shared_example.rb index da1074e5932..b65b1300769 100644 --- a/spec/support/features/discussion_comments_shared_example.rb +++ b/spec/support/features/discussion_comments_shared_example.rb @@ -14,6 +14,8 @@ shared_examples 'discussion comments' do |resource_name| find(submit_selector).click + wait_for_requests + find(comments_selector, match: :first) new_comment = all(comments_selector).last @@ -26,6 +28,7 @@ shared_examples 'discussion comments' do |resource_name| find("#{form_selector} .note-textarea").send_keys('a') find(close_selector).click + wait_for_requests find(comments_selector, match: :first) find("#{comments_selector}.system-note") @@ -76,12 +79,22 @@ shared_examples 'discussion comments' do |resource_name| it 'clicking the ul padding or divider should not change the text' do find(menu_selector).click - expect(page).to have_selector menu_selector - expect(find(dropdown_selector)).to have_content 'Comment' + if resource_name == 'issue' + expect(find(dropdown_selector)).to have_content 'Comment' + + find(toggle_selector).click + find("#{menu_selector} .divider").click + else + find(menu_selector).click - find("#{menu_selector} .divider").click + expect(page).to have_selector menu_selector + expect(find(dropdown_selector)).to have_content 'Comment' + + find("#{menu_selector} .divider").click + + expect(page).to have_selector menu_selector + end - expect(page).to have_selector menu_selector expect(find(dropdown_selector)).to have_content 'Comment' end @@ -91,9 +104,8 @@ shared_examples 'discussion comments' do |resource_name| all("#{menu_selector} li").last.click end - it 'updates the submit button text, note_type input and closes the dropdown' do + it 'updates the submit button text and closes the dropdown' do expect(find(dropdown_selector)).to have_content 'Start discussion' - expect(find("#{form_selector} #note_type", visible: false).value).to eq('DiscussionNote') expect(page).not_to have_selector menu_selector end @@ -157,9 +169,8 @@ shared_examples 'discussion comments' do |resource_name| find("#{menu_selector} li", match: :first).click end - it 'updates the submit button text, clears the note_type input and closes the dropdown' do + it 'updates the submit button text and closes the dropdown' do expect(find(dropdown_selector)).to have_content 'Comment' - expect(find("#{form_selector} #note_type", visible: false).value).to eq('') expect(page).not_to have_selector menu_selector end diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 68f0ce8afb3..8282ba7e536 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -21,7 +21,7 @@ shared_examples 'issuable record that supports quick actions in its description before do project.team << [master, :master] - sign_in(master) + gitlab_sign_in(master) end after do @@ -119,16 +119,15 @@ shared_examples 'issuable record that supports quick actions in its description guest = create(:user) project.add_guest(guest) - sign_out(:user) - sign_in(guest) - + gitlab_sign_out + gitlab_sign_in(guest) visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) end it "does not close the #{issuable_type}" do write_note("/close") - expect(page).not_to have_content '/close' + expect(page).to have_content '/close' expect(page).not_to have_content 'Commands applied' expect(issuable).to be_open @@ -158,16 +157,15 @@ shared_examples 'issuable record that supports quick actions in its description guest = create(:user) project.add_guest(guest) - sign_out(:user) - sign_in(guest) - + gitlab_sign_out + gitlab_sign_in(guest) visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) end it "does not reopen the #{issuable_type}" do write_note("/reopen") - expect(page).not_to have_content '/reopen' + expect(page).to have_content '/reopen' expect(page).not_to have_content 'Commands applied' expect(issuable).to be_closed @@ -192,15 +190,15 @@ shared_examples 'issuable record that supports quick actions in its description guest = create(:user) project.add_guest(guest) - sign_out(:user) - sign_in(guest) + gitlab_sign_out + gitlab_sign_in(guest) visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) end it "does not reopen the #{issuable_type}" do write_note("/title Awesome new title") - expect(page).not_to have_content '/title' + expect(page).to have_content '/title' expect(page).not_to have_content 'Commands applied' expect(issuable.reload.title).not_to eq 'Awesome new title' @@ -292,7 +290,7 @@ shared_examples 'issuable record that supports quick actions in its description end end - describe "preview of note on #{issuable_type}" do + describe "preview of note on #{issuable_type}", js: true do it 'removes quick actions from note and explains them' do create(:user, username: 'bob') diff --git a/spec/support/features/reportable_note_shared_examples.rb b/spec/support/features/reportable_note_shared_examples.rb index 5a0e7c3d099..192a2fed0a8 100644 --- a/spec/support/features/reportable_note_shared_examples.rb +++ b/spec/support/features/reportable_note_shared_examples.rb @@ -1,6 +1,6 @@ require 'spec_helper' -shared_examples 'reportable note' do +shared_examples 'reportable note' do |type| include NotesHelper let(:comment) { find("##{ActionView::RecordIdentifier.dom_id(note)}") } @@ -20,7 +20,12 @@ shared_examples 'reportable note' do open_dropdown(dropdown) expect(dropdown).to have_link('Report as abuse', href: abuse_report_path) - expect(dropdown).to have_link('Delete comment', href: note_url(note, project)) + + if type == 'issue' + expect(dropdown).to have_button('Delete comment') + else + expect(dropdown).to have_link('Delete comment', href: note_url(note, project)) + end end it 'Report button links to a report page' do diff --git a/spec/support/javascript_fixtures_helpers.rb b/spec/support/javascript_fixtures_helpers.rb index aace4b3adee..923c8080e6c 100644 --- a/spec/support/javascript_fixtures_helpers.rb +++ b/spec/support/javascript_fixtures_helpers.rb @@ -31,6 +31,10 @@ module JavaScriptFixturesHelpers File.write(fixture_file_name, fixture) end + def remove_repository(project) + Gitlab::Shell.new.remove_repository(project.repository_storage_path, project.disk_path) + end + private # Private: Prepare a response object for use as a frontend fixture diff --git a/spec/support/notify_shared_examples.rb b/spec/support/notify_shared_examples.rb index 136f92c6419..e2c23607406 100644 --- a/spec/support/notify_shared_examples.rb +++ b/spec/support/notify_shared_examples.rb @@ -1,9 +1,10 @@ shared_context 'gitlab email notification' do + set(:project) { create(:project, :repository) } + set(:recipient) { create(:user, email: 'recipient@example.com') } + let(:gitlab_sender_display_name) { Gitlab.config.gitlab.email_display_name } let(:gitlab_sender) { Gitlab.config.gitlab.email_from } let(:gitlab_sender_reply_to) { Gitlab.config.gitlab.email_reply_to } - let(:recipient) { create(:user, email: 'recipient@example.com') } - let(:project) { create(:project) } let(:new_user_address) { 'newguy@example.com' } before do diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 1e39f80699c..290ded3ff7e 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -5,7 +5,7 @@ module TestEnv # When developing the seed repository, comment out the branch you will modify. BRANCH_SHA = { - 'signed-commits' => '5d4a1cb', + 'signed-commits' => '2d1096e', 'not-merged-branch' => 'b83d6e3', 'branch-merged' => '498214d', 'empty-branch' => '7efb185', diff --git a/spec/views/ci/lints/show.html.haml_spec.rb b/spec/views/ci/lints/show.html.haml_spec.rb index 3390ae247ff..f2c19c7642a 100644 --- a/spec/views/ci/lints/show.html.haml_spec.rb +++ b/spec/views/ci/lints/show.html.haml_spec.rb @@ -73,8 +73,8 @@ describe 'ci/lints/show' do render expect(rendered).to have_content('Tag list: dotnet') - expect(rendered).to have_content('Refs only: test@dude/repo') - expect(rendered).to have_content('Refs except: deploy') + expect(rendered).to have_content('Only policy: refs, test@dude/repo') + expect(rendered).to have_content('Except policy: refs, deploy') expect(rendered).to have_content('Environment: testing') expect(rendered).to have_content('When: on_success') end diff --git a/spec/views/projects/jobs/show.html.haml_spec.rb b/spec/views/projects/jobs/show.html.haml_spec.rb index 117f48450e2..d4279626e75 100644 --- a/spec/views/projects/jobs/show.html.haml_spec.rb +++ b/spec/views/projects/jobs/show.html.haml_spec.rb @@ -195,20 +195,4 @@ describe 'projects/jobs/show' do text: /\A\n#{Regexp.escape(commit_title)}\n\Z/) end end - - describe 'shows trigger variables in sidebar' do - let(:trigger_request) { create(:ci_trigger_request_with_variables, pipeline: pipeline) } - - before do - build.trigger_request = trigger_request - render - end - - it 'shows trigger variables in separate lines' do - expect(rendered).to have_css('.js-build-variable', visible: false, text: 'TRIGGER_KEY_1') - expect(rendered).to have_css('.js-build-variable', visible: false, text: 'TRIGGER_KEY_2') - expect(rendered).to have_css('.js-build-value', visible: false, text: 'TRIGGER_VALUE_1') - expect(rendered).to have_css('.js-build-value', visible: false, text: 'TRIGGER_VALUE_2') - end - end end diff --git a/spec/workers/create_gpg_signature_worker_spec.rb b/spec/workers/create_gpg_signature_worker_spec.rb index 54978baca88..aa6c347d738 100644 --- a/spec/workers/create_gpg_signature_worker_spec.rb +++ b/spec/workers/create_gpg_signature_worker_spec.rb @@ -7,9 +7,14 @@ describe CreateGpgSignatureWorker do let(:commit_sha) { '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33' } it 'calls Gitlab::Gpg::Commit#signature' do - expect(Gitlab::Gpg::Commit).to receive(:new).with(project, commit_sha).and_call_original + commit = instance_double(Commit) + gpg_commit = instance_double(Gitlab::Gpg::Commit) - expect_any_instance_of(Gitlab::Gpg::Commit).to receive(:signature) + allow(Project).to receive(:find_by).with(id: project.id).and_return(project) + allow(project).to receive(:commit).with(commit_sha).and_return(commit) + + expect(Gitlab::Gpg::Commit).to receive(:new).with(commit).and_return(gpg_commit) + expect(gpg_commit).to receive(:signature) described_class.new.perform(commit_sha, project.id) end diff --git a/spec/workers/git_garbage_collect_worker_spec.rb b/spec/workers/git_garbage_collect_worker_spec.rb index 05f971dfd13..c4979792194 100644 --- a/spec/workers/git_garbage_collect_worker_spec.rb +++ b/spec/workers/git_garbage_collect_worker_spec.rb @@ -23,8 +23,8 @@ describe GitGarbageCollectWorker do expect_any_instance_of(Repository).to receive(:after_create_branch).and_call_original expect_any_instance_of(Repository).to receive(:branch_names).and_call_original - expect_any_instance_of(Repository).to receive(:branch_count).and_call_original - expect_any_instance_of(Repository).to receive(:has_visible_content?).and_call_original + expect_any_instance_of(Gitlab::Git::Repository).to receive(:branch_count).and_call_original + expect_any_instance_of(Gitlab::Git::Repository).to receive(:has_visible_content?).and_call_original subject.perform(project.id) end @@ -143,7 +143,7 @@ describe GitGarbageCollectWorker do tree: old_commit.tree, parents: [old_commit] ) - GitOperationService.new(nil, project.repository).send( + Gitlab::Git::OperationService.new(nil, project.repository.raw_repository).send( :update_ref, "refs/heads/#{SecureRandom.hex(6)}", new_commit_sha, diff --git a/spec/workers/stuck_ci_jobs_worker_spec.rb b/spec/workers/stuck_ci_jobs_worker_spec.rb index 549635f7f33..ac6f4fefb4e 100644 --- a/spec/workers/stuck_ci_jobs_worker_spec.rb +++ b/spec/workers/stuck_ci_jobs_worker_spec.rb @@ -6,27 +6,31 @@ describe StuckCiJobsWorker do let(:worker) { described_class.new } let(:exclusive_lease_uuid) { SecureRandom.uuid } - subject do - job.reload - job.status - end - before do job.update!(status: status, updated_at: updated_at) allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return(exclusive_lease_uuid) end shared_examples 'job is dropped' do - it 'changes status' do + before do worker.perform - is_expected.to eq('failed') + job.reload + end + + it "changes status" do + expect(job).to be_failed + expect(job).to be_stuck_or_timeout_failure end end shared_examples 'job is unchanged' do - it "doesn't change status" do + before do worker.perform - is_expected.to eq(status) + job.reload + end + + it "doesn't change status" do + expect(job.status).to eq(status) end end diff --git a/vendor/gitignore/Global/JetBrains.gitignore b/vendor/gitignore/Global/JetBrains.gitignore index ff23445e2b0..345e61ae3f2 100644 --- a/vendor/gitignore/Global/JetBrains.gitignore +++ b/vendor/gitignore/Global/JetBrains.gitignore @@ -31,7 +31,7 @@ cmake-build-debug/ ## Plugin-specific files: # IntelliJ -/out/ +out/ # mpeltonen/sbt-idea plugin .idea_modules/ diff --git a/vendor/gitignore/Haskell.gitignore b/vendor/gitignore/Haskell.gitignore index 450f32ec40c..eee88b2f0f7 100644 --- a/vendor/gitignore/Haskell.gitignore +++ b/vendor/gitignore/Haskell.gitignore @@ -18,3 +18,4 @@ cabal.sandbox.config .stack-work/ cabal.project.local .HTF/ +.ghc.environment.* diff --git a/vendor/gitignore/Prestashop.gitignore b/vendor/gitignore/Prestashop.gitignore index 7c6ae1e31cc..81f45e19eba 100644 --- a/vendor/gitignore/Prestashop.gitignore +++ b/vendor/gitignore/Prestashop.gitignore @@ -7,8 +7,10 @@ config/settings.*.php # The following files are generated by PrestaShop. admin-dev/autoupgrade/ -/cache/ +/cache/* !/cache/index.php +!/cache/*/ +/cache/*/* !/cache/cachefs/index.php !/cache/purifier/index.php !/cache/push/index.php diff --git a/vendor/gitignore/Smalltalk.gitignore b/vendor/gitignore/Smalltalk.gitignore index 75272b23472..943995e1172 100644 --- a/vendor/gitignore/Smalltalk.gitignore +++ b/vendor/gitignore/Smalltalk.gitignore @@ -13,6 +13,10 @@ SqueakDebug.log # Monticello package cache /package-cache +# playground cache +/play-cache +/play-stash + # Metacello-github cache /github-cache github-*.zip diff --git a/vendor/gitignore/Symfony.gitignore b/vendor/gitignore/Symfony.gitignore index 6c224e024e9..85fd714a965 100644 --- a/vendor/gitignore/Symfony.gitignore +++ b/vendor/gitignore/Symfony.gitignore @@ -39,3 +39,6 @@ # Backup entities generated with doctrine:generate:entities command **/Entity/*~ + +# Embedded web-server pid file +/.web-server-pid diff --git a/vendor/gitignore/VisualStudio.gitignore b/vendor/gitignore/VisualStudio.gitignore index 22fd88a55a3..89c66054885 100644 --- a/vendor/gitignore/VisualStudio.gitignore +++ b/vendor/gitignore/VisualStudio.gitignore @@ -151,7 +151,7 @@ publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml -# TODO: Comment the next line if you want to checkin your web deploy settings +# Note: Comment the next line if you want to checkin your web deploy settings, # but database connection strings (with potential passwords) will be unencrypted *.pubxml *.publishproj diff --git a/vendor/gitlab-ci-yml/Go.gitlab-ci.yml b/vendor/gitlab-ci-yml/Go.gitlab-ci.yml index e23b6e212f0..8a214352d2a 100644 --- a/vendor/gitlab-ci-yml/Go.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Go.gitlab-ci.yml @@ -1,14 +1,19 @@ image: golang:latest +variables: + # Please edit to your GitLab project + REPO_NAME: gitlab.com/namespace/project + # The problem is that to be able to use go get, one needs to put # the repository in the $GOPATH. So for example if your gitlab domain -# is mydomainperso.com, and that your repository is repos/projectname, and +# is gitlab.com, and that your repository is namespace/project, and # the default GOPATH being /go, then you'd need to have your -# repository in /go/src/mydomainperso.com/repos/projectname +# repository in /go/src/gitlab.com/namespace/project # Thus, making a symbolic link corrects this. before_script: - - ln -s /builds /go/src/mydomainperso.com - - cd /go/src/mydomainperso.com/repos/projectname + - mkdir -p $GOPATH/src/$REPO_NAME + - ln -svf $CI_PROJECT_DIR/* $GOPATH/src/$REPO_NAME + - cd $GOPATH/src/$REPO_NAME stages: - test @@ -17,21 +22,14 @@ stages: format: stage: test script: - # Add here all the dependencies, or use glide/govendor to get - # them automatically. - # - curl https://glide.sh/get | sh - - go get github.com/alecthomas/kingpin - - go tool vet -composites=false -shadow=true *.go - - go test -race $(go list ./... | grep -v /vendor/) + - go fmt $(go list ./... | grep -v /vendor/) + - go vet $(go list ./... | grep -v /vendor/) + - go test -race $(go list ./... | grep -v /vendor/) compile: stage: build script: - # Add here all the dependencies, or use glide/govendor/... - # to get them automatically. - - go get github.com/alecthomas/kingpin - # Better put this in a Makefile - - go build -race -ldflags "-extldflags '-static'" -o mybinary + - go build -race -ldflags "-extldflags '-static'" -o mybinary artifacts: - paths: - - mybinary + paths: + - mybinary diff --git a/vendor/gitlab-ci-yml/Gradle.gitlab-ci.yml b/vendor/gitlab-ci-yml/Gradle.gitlab-ci.yml index a65e48a3389..48d98dddfad 100644 --- a/vendor/gitlab-ci-yml/Gradle.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Gradle.gitlab-ci.yml @@ -1,41 +1,36 @@ -# This template uses the java:8 docker image because there isn't any -# official Gradle image at this moment -# # This is the Gradle build system for JVM applications # https://gradle.org/ # https://github.com/gradle/gradle -image: java:8 +image: gradle:alpine # Disable the Gradle daemon for Continuous Integration servers as correctness # is usually a priority over speed in CI environments. Using a fresh # runtime for each build is more reliable since the runtime is completely # isolated from any previous builds. variables: - GRADLE_OPTS: "-Dorg.gradle.daemon=false" + GRADLE_OPTS: "-Dorg.gradle.daemon=false" -# Make the gradle wrapper executable. This essentially downloads a copy of -# Gradle to build the project with. -# https://docs.gradle.org/current/userguide/gradle_wrapper.html -# It is expected that any modern gradle project has a wrapper before_script: - - chmod +x gradlew + - export GRADLE_USER_HOME=`pwd`/.gradle -# We redirect the gradle user home using -g so that it caches the -# wrapper and dependencies. -# https://docs.gradle.org/current/userguide/gradle_command_line.html -# -# Unfortunately it also caches the build output so -# cleaning removes reminants of any cached builds. -# The assemble task actually builds the project. -# If it fails here, the tests can't run. build: stage: build - script: - - ./gradlew -g /cache/.gradle clean assemble - allow_failure: false + script: gradle --build-cache assemble + cache: + key: "$CI_COMMIT_REF_NAME" + policy: push + paths: + - build + - .gradle + -# Use the generated build output to run the tests. test: stage: test - script: - - ./gradlew -g /cache/.gradle check + script: gradle check + cache: + key: "$CI_COMMIT_REF_NAME" + policy: pull + paths: + - build + - .gradle + diff --git a/vendor/gitlab-ci-yml/Laravel.gitlab-ci.yml b/vendor/gitlab-ci-yml/Laravel.gitlab-ci.yml index 434de4f055a..0ad662cf704 100644 --- a/vendor/gitlab-ci-yml/Laravel.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Laravel.gitlab-ci.yml @@ -34,6 +34,10 @@ before_script: # Install php extensions - docker-php-ext-install mbstring mcrypt pdo_mysql curl json intl gd xml zip bz2 opcache + # Install & enable Xdebug for code coverage reports + - pecl install xdebug + - docker-php-ext-enable xdebug + # Install Composer and project dependencies. - curl -sS https://getcomposer.org/installer | php - php composer.phar install diff --git a/vendor/gitlab-ci-yml/PHP.gitlab-ci.yml b/vendor/gitlab-ci-yml/PHP.gitlab-ci.yml index bb8caa49d6b..33f44ee9222 100644 --- a/vendor/gitlab-ci-yml/PHP.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/PHP.gitlab-ci.yml @@ -11,6 +11,9 @@ before_script: - apt-get install -yqq git libmcrypt-dev libpq-dev libcurl4-gnutls-dev libicu-dev libvpx-dev libjpeg-dev libpng-dev libxpm-dev zlib1g-dev libfreetype6-dev libxml2-dev libexpat1-dev libbz2-dev libgmp3-dev libldap2-dev unixodbc-dev libsqlite3-dev libaspell-dev libsnmp-dev libpcre3-dev libtidy-dev # Install PHP extensions - docker-php-ext-install mbstring mcrypt pdo_pgsql curl json intl gd xml zip bz2 opcache +# Install & enable Xdebug for code coverage reports +- pecl install xdebug +- docker-php-ext-enable xdebug # Install and run Composer - curl -sS https://getcomposer.org/installer | php - php composer.phar install diff --git a/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml b/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml index 4e181e85451..ff7bdd32239 100644 --- a/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml +++ b/vendor/gitlab-ci-yml/Ruby.gitlab-ci.yml @@ -1,6 +1,6 @@ # Official language image. Look for the different tagged releases at: # https://hub.docker.com/r/library/ruby/tags/ -image: "ruby:2.3" +image: "ruby:2.4" # Pick zero or more services to be used on all builds. # Only needed when using a docker container to run your tests in. @@ -40,9 +40,9 @@ rails: variables: DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/$POSTGRES_DB" script: - - bundle exec rake db:migrate - - bundle exec rake db:seed - - bundle exec rake test + - rails db:migrate + - rails db:seed + - rails test # This deploy job uses a simple deploy flow to Heroku, other providers, e.g. AWS Elastic Beanstalk # are supported too: https://github.com/travis-ci/dpl diff --git a/yarn.lock b/yarn.lock index 5245666fa43..de4a9ac4487 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6307,6 +6307,10 @@ vue@^2.2.6: version "2.2.6" resolved "https://registry.yarnpkg.com/vue/-/vue-2.2.6.tgz#451714b394dd6d4eae7b773c40c2034a59621aed" +vuex@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/vuex/-/vuex-2.3.1.tgz#cde8e997c1f9957719bc7dea154f9aa691d981a6" + watchpack@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.4.0.tgz#4a1472bcbb952bd0a9bb4036801f954dfb39faac" |