diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000000000000000000000000000000000000..e5636a13783cb5985088231823fc6ff551405b71 --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,38 @@ +--- +engines: + brakeman: + enabled: true + bundler-audit: + enabled: true + duplication: + enabled: true + config: + languages: + - ruby + - javascript + eslint: + enabled: true + fixme: + enabled: true + rubocop: + enabled: true +ratings: + paths: + - Gemfile.lock + - "**.erb" + - "**.haml" + - "**.rb" + - "**.rhtml" + - "**.slim" + - "**.inc" + - "**.js" + - "**.jsx" + - "**.module" +exclude_paths: +- config/ +- db/ +- features/ +- node_modules/ +- spec/ +- vendor/ +- lib/api/v3/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0123b9b214f12d44bdf1bf2aa2ad5c55743164b1..dea11bb9f61eee699012bf0b7cf2a784e5713091 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -52,7 +52,7 @@ stages: .use-pg: &use-pg services: - - postgres:9.1 + - postgres:9.2 - redis:alpine .use-mysql: &use-mysql @@ -487,25 +487,6 @@ lint:javascript:report: paths: - eslint-report.html -# Trigger docs build -# https://gitlab.com/gitlab-com/doc-gitlab-com/blob/master/README.md#deployment-process -trigger_docs: - stage: post-test - image: "alpine" - <<: *dedicated-runner - before_script: - - apk update && apk add curl - variables: - GIT_STRATEGY: "none" - cache: {} - artifacts: {} - script: - - "HTTP_STATUS=$(curl -X POST -F token=${DOCS_TRIGGER_TOKEN} -F ref=master -F variables[PROJECT]=${CI_PROJECT_NAME} --silent --output curl.log --write-out '%{http_code}' https://gitlab.com/api/v3/projects/1794617/trigger/builds)" - - if [ "${HTTP_STATUS}" -ne "201" ]; then echo "Error ${HTTP_STATUS}"; cat curl.log; echo; exit 1; fi - only: - - master@gitlab-org/gitlab-ce - - master@gitlab-org/gitlab-ee - pages: before_script: [] stage: pages diff --git a/.scss-lint.yml b/.scss-lint.yml index 83c68309fa820ed30920f37b84803bc2267e18f9..db234ad739cb4f522c40c9bfed38bffc1167927d 100644 --- a/.scss-lint.yml +++ b/.scss-lint.yml @@ -11,11 +11,11 @@ linters: # !global, !important, and !optional flags. BangFormat: enabled: false - + # Whether or not to prefer `border: 0` over `border: none`. BorderZero: enabled: false - + # Reports when you define a rule set using a selector with chained classes # (a.k.a. adjoining classes). ChainedClasses: @@ -25,13 +25,13 @@ linters: # (e.g. `color: green` is a color keyword) ColorKeyword: enabled: false - + # Prefer color literals (keywords or hexadecimal codes) to be used only in # variable declarations. They should be referred to via variables everywhere # else. ColorVariable: enabled: true - + # Which form of comments to prefer in CSS. Comment: enabled: false @@ -39,7 +39,7 @@ linters: # Reports @debug statements (which you probably left behind accidentally). DebugStatement: enabled: false - + # Rule sets should be ordered as follows: # - @extend declarations # - @include declarations without inner @content @@ -54,19 +54,19 @@ linters: # more information. DisableLinterReason: enabled: true - + # Reports when you define the same property twice in a single rule set. DuplicateProperty: - enabled: false - + enabled: true + # Separate rule, function, and mixin declarations with empty lines. EmptyLineBetweenBlocks: enabled: true - + # Reports when you have an empty rule set. EmptyRule: enabled: true - + # Reports when you have an @extend directive. ExtendDirective: enabled: false @@ -75,49 +75,49 @@ linters: # when adding lines to the file, since SCM systems such as git won't # think that you touched the last line. FinalNewline: - enabled: false - + enabled: true + # HEX colors should use three-character values where possible. HexLength: enabled: false - + # HEX color values should use lower-case colors to differentiate between # letters and numbers, e.g. `#E3E3E3` vs. `#e3e3e3`. HexNotation: enabled: true - + # Avoid using ID selectors. IdSelector: enabled: false - + # The basenames of @imported SCSS partials should not begin with an # underscore and should not include the filename extension. ImportPath: enabled: false - + # Avoid using !important in properties. It is usually indicative of a # misunderstanding of CSS specificity and can lead to brittle code. ImportantRule: enabled: false - + # Indentation should always be done in increments of 2 spaces. Indentation: enabled: true width: 2 - + # Don't write leading zeros for numeric values with a decimal point. LeadingZero: enabled: false - + # Reports when you define the same selector twice in a single sheet. MergeableSelector: enabled: false - + # Functions, mixins, variables, and placeholders should be declared # with all lowercase letters and hyphens instead of underscores. NameFormat: enabled: false - + # Avoid nesting selectors too deeply. NestingDepth: enabled: false @@ -129,12 +129,12 @@ linters: # Sort properties in a strict order. PropertySortOrder: enabled: false - + # Reports when you use an unknown or disabled CSS property # (ignoring vendor-prefixed properties). PropertySpelling: enabled: false - + # Configure which units are allowed for property values. PropertyUnits: enabled: false @@ -144,25 +144,25 @@ linters: # be declared with one colon. PseudoElement: enabled: true - + # Avoid qualifying elements in selectors (also known as "tag-qualifying"). QualifyingElement: enabled: false - + # Don't write selectors with a depth of applicability greater than 3. SelectorDepth: enabled: false - + # Selectors should always use hyphenated-lowercase, rather than camelCase or # snake_case. SelectorFormat: enabled: false convention: hyphenated_lowercase - + # Prefer the shortest shorthand form possible for properties that support it. Shorthand: enabled: true - + # Each property should have its own line, except in the special case of # single line rulesets. SingleLinePerProperty: @@ -173,11 +173,11 @@ linters: # individual selector occupy a single line. SingleLinePerSelector: enabled: true - + # Commas in lists should be followed by a space. SpaceAfterComma: enabled: false - + # Properties should be formatted with a single space separating the colon # from the property's value. SpaceAfterPropertyColon: @@ -197,12 +197,12 @@ linters: # colon. SpaceAfterVariableName: enabled: false - + # Operators should be formatted with a single space on both sides of an # infix operator. SpaceAroundOperator: enabled: true - + # Opening braces should be preceded by a single space. SpaceBeforeBrace: enabled: true @@ -210,7 +210,7 @@ linters: # Parentheses should not be padded with spaces. SpaceBetweenParens: enabled: false - + # Enforces that string literals should be written with a consistent form # of quotes (single or double). StringQuotes: @@ -241,7 +241,7 @@ linters: # be unnecessary. UnnecessaryParentReference: enabled: false - + # URLs should be valid and not contain protocols or domain names. UrlFormat: enabled: true diff --git a/Gemfile.lock b/Gemfile.lock index dd2c85052f3226cb056841b90059e40b1ea7ecb1..273a69792ef4cbdb1ae54041855abd0a6b196c79 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -341,7 +341,7 @@ GEM grape-entity (0.6.0) activesupport multi_json (>= 1.3.2) - grpc (1.3.4) + grpc (1.2.5) google-protobuf (~> 3.1) googleauth (~> 0.5.1) haml (4.0.7) @@ -499,11 +499,10 @@ GEM omniauth (~> 1.0) omniauth-oauth2 (~> 1.0) omniauth-google-oauth2 (0.4.1) - addressable (~> 2.3) - jwt (~> 1.0) + jwt (~> 1.5.2) multi_json (~> 1.3) omniauth (>= 1.1.1) - omniauth-oauth2 (~> 1.3.1) + omniauth-oauth2 (>= 1.3.1) omniauth-kerberos (0.3.0) omniauth-multipassword timfel-krb5-auth (~> 0.8) @@ -1060,4 +1059,4 @@ DEPENDENCIES wikicloth (= 0.8.1) BUNDLED WITH - 1.14.6 + 1.15.0 diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index d7c62889dde9c838ec52c008cb2ce52076c0676e..187fab084fdac7fb6f8269a30add6aa1ab79717b 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -111,7 +111,7 @@ export default class BlobViewer { BlobViewer.loadViewer(newViewer) .then((viewer) => { - $(viewer).syntaxHighlight(); + $(viewer).renderGFM(); this.$fileHolder.trigger('highlight:line'); gl.utils.handleLocationHash(); diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index e0a6f64dd423aeff16cfcb5e2fba44c3f82ac9b8..0e4aa39226bff14431474d9d1a7645f4e43c8e62 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -70,6 +70,7 @@ $(() => { gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId); this.filterManager = new FilteredSearchBoards(Store.filter, true); + this.filterManager.setup(); // Listen for updateTokens event eventHub.$on('updateTokens', this.updateTokens); diff --git a/app/assets/javascripts/boards/components/modal/filters.js b/app/assets/javascripts/boards/components/modal/filters.js index b214b5a71994d86528957db4afb9529ec7a065c4..56a0fde5a9143c17b858fd47d0d32bf633ef9011 100644 --- a/app/assets/javascripts/boards/components/modal/filters.js +++ b/app/assets/javascripts/boards/components/modal/filters.js @@ -13,6 +13,7 @@ export default { FilteredSearchContainer.container = this.$el; this.filteredSearch = new FilteredSearchBoards(this.store); + this.filteredSearch.setup(); this.filteredSearch.removeTokens(); this.filteredSearch.handleInputPlaceholder(); this.filteredSearch.toggleClearSearchButton(); diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 1264280284cbd3ce6f7063b49bd6db8c616223ef..b37698fe9caa399bcbc3d402988424eb394f3720 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -2,7 +2,7 @@ import FilteredSearchContainer from '../filtered_search/container'; export default class FilteredSearchBoards extends gl.FilteredSearchManager { - constructor(store, updateUrl = false) { + constructor(store, updateUrl = false, cantEdit = []) { super('boards'); this.store = store; @@ -11,6 +11,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { // Issue boards is slightly different, we handle all the requests async // instead or reloading the page, we just re-fire the list ajax requests this.isHandledAsync = true; + + this.cantEdit = cantEdit; } updateObject(path) { @@ -40,4 +42,8 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { // Get the placeholder back if search is empty this.filteredSearchInput.dispatchEvent(new Event('input')); } + + canEdit(tokenName) { + return this.cantEdit.indexOf(tokenName) === -1; + } } diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index 97f279e4be4556a7be656d256a46d001e2ac14ac..1a602cbd8a7f68dea46031d087579f604faeb1b2 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -2,15 +2,11 @@ consistent-return, prefer-rest-params */ /* global Breakpoints */ +import _ from 'underscore'; import { bytesToKiB } from './lib/utils/number_utils'; -const bind = function (fn, me) { return function () { return fn.apply(me, arguments); }; }; -const AUTO_SCROLL_OFFSET = 75; -const DOWN_BUILD_TRACE = '#down-build-trace'; - window.Build = (function () { Build.timeout = null; - Build.state = null; function Build(options) { @@ -23,21 +19,22 @@ window.Build = (function () { this.buildStage = this.options.buildStage; this.$document = $(document); this.logBytes = 0; + this.scrollOffsetPadding = 30; - this.updateDropdown = bind(this.updateDropdown, this); + this.updateDropdown = this.updateDropdown.bind(this); + this.getBuildTrace = this.getBuildTrace.bind(this); + this.scrollToBottom = this.scrollToBottom.bind(this); this.$body = $('body'); this.$buildTrace = $('#build-trace'); - this.$autoScrollContainer = $('.autoscroll-container'); - this.$autoScrollStatus = $('#autoscroll-status'); - this.$autoScrollStatusText = this.$autoScrollStatus.find('.status-text'); - this.$upBuildTrace = $('#up-build-trace'); - this.$downBuildTrace = $(DOWN_BUILD_TRACE); - this.$scrollTopBtn = $('#scroll-top'); - this.$scrollBottomBtn = $('#scroll-bottom'); this.$buildRefreshAnimation = $('.js-build-refresh'); - this.$buildScroll = $('#js-build-scroll'); this.$truncatedInfo = $('.js-truncated-info'); + this.$buildTraceOutput = $('.js-build-output'); + this.$scrollContainer = $('.js-scroll-container'); + + // Scroll controllers + this.$scrollTopBtn = $('.js-scroll-up'); + this.$scrollBottomBtn = $('.js-scroll-down'); clearTimeout(Build.timeout); // Init breakpoint checker @@ -56,54 +53,149 @@ window.Build = (function () { .off('click', '.stage-item') .on('click', '.stage-item', this.updateDropdown); - this.$document.on('scroll', this.initScrollMonitor.bind(this)); + // add event listeners to the scroll buttons + this.$scrollTopBtn + .off('click') + .on('click', this.scrollToTop.bind(this)); + + this.$scrollBottomBtn + .off('click') + .on('click', this.scrollToBottom.bind(this)); $(window) .off('resize.build') .on('resize.build', this.sidebarOnResize.bind(this)); - $('a', this.$buildScroll) - .off('click.stepTrace') - .on('click.stepTrace', this.stepTrace); - this.updateArtifactRemoveDate(); - this.initScrollButtonAffix(); - this.invokeBuildTrace(); + + // eslint-disable-next-line + this.getBuildTrace() + .then(() => this.makeTraceScrollable()) + .then(() => this.scrollToBottom()); + + this.verifyTopPosition(); } + Build.prototype.makeTraceScrollable = function () { + this.$scrollContainer.niceScroll({ + cursorcolor: '#fff', + cursoropacitymin: 1, + cursorwidth: '3px', + railpadding: { top: 5, bottom: 5, right: 5 }, + }); + + this.$scrollContainer.on('scroll', _.throttle(this.toggleScroll.bind(this), 100)); + + this.toggleScroll(); + }; + + Build.prototype.canScroll = function () { + return (this.$scrollContainer.prop('scrollHeight') - this.scrollOffsetPadding) > this.$scrollContainer.height(); + }; + + /** + * | | Up | Down | + * |--------------------------|----------|----------| + * | on scroll bottom | active | disabled | + * | on scroll top | disabled | active | + * | no scroll | disabled | disabled | + * | on.('scroll') is on top | disabled | active | + * | on('scroll) is on bottom | active | disabled | + * + */ + Build.prototype.toggleScroll = function () { + const bottomScroll = this.$scrollContainer.scrollTop() + + this.scrollOffsetPadding + + this.$scrollContainer.height(); + + if (this.canScroll()) { + if (this.$scrollContainer.scrollTop() === 0) { + this.toggleDisableButton(this.$scrollTopBtn, true); + this.toggleDisableButton(this.$scrollBottomBtn, false); + } else if (bottomScroll === this.$scrollContainer.prop('scrollHeight')) { + this.toggleDisableButton(this.$scrollTopBtn, false); + this.toggleDisableButton(this.$scrollBottomBtn, true); + } else { + this.toggleDisableButton(this.$scrollTopBtn, false); + this.toggleDisableButton(this.$scrollBottomBtn, false); + } + } + }; + + Build.prototype.scrollToTop = function () { + this.$scrollContainer.getNiceScroll(0).doScrollTop(0); + this.toggleScroll(); + }; + + Build.prototype.scrollToBottom = function () { + this.$scrollContainer.getNiceScroll(0).doScrollTo(this.$scrollContainer.prop('scrollHeight')); + this.toggleScroll(); + }; + + Build.prototype.toggleDisableButton = function ($button, disable) { + if (disable && $button.prop('disabled')) return; + $button.prop('disabled', disable); + }; + + Build.prototype.toggleScrollAnimation = function (toggle) { + this.$scrollBottomBtn.toggleClass('animate', toggle); + }; + + /** + * Build trace top position depends on the space ocupied by the elments rendered before + */ + Build.prototype.verifyTopPosition = function () { + const $buildPage = $('.build-page'); + + const $header = $('.build-header', $buildPage); + const $runnersStuck = $('.js-build-stuck', $buildPage); + const $startsEnvironment = $('.js-environment-container', $buildPage); + const $erased = $('.js-build-erased', $buildPage); + + let topPostion = 168; + + if ($header) { + topPostion += $header.outerHeight(); + } + + if ($runnersStuck) { + topPostion += $runnersStuck.outerHeight(); + } + + if ($startsEnvironment) { + topPostion += $startsEnvironment.outerHeight(); + } + + if ($erased) { + topPostion += $erased.outerHeight() + 10; + } + + this.$buildTrace.css({ + top: topPostion, + }); + }; + Build.prototype.initSidebar = function () { this.$sidebar = $('.js-build-sidebar'); this.$sidebar.niceScroll(); - this.$document - .off('click', '.js-sidebar-build-toggle') - .on('click', '.js-sidebar-build-toggle', this.toggleSidebar); - }; - - Build.prototype.invokeBuildTrace = function () { - return this.getBuildTrace(); }; Build.prototype.getBuildTrace = function () { return $.ajax({ url: `${this.pageUrl}/trace.json`, - dataType: 'json', - data: { - state: this.state, - }, - success: ((log) => { - const $buildContainer = $('.js-build-output'); - + data: this.state, + }) + .done((log) => { gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`); - if (log.state) { this.state = log.state; } if (log.append) { - $buildContainer.append(log.html); + this.$buildTraceOutput.append(log.html); this.logBytes += log.size; } else { - $buildContainer.html(log.html); + this.$buildTraceOutput.html(log.html); this.logBytes = log.size; } @@ -114,141 +206,30 @@ window.Build = (function () { const size = bytesToKiB(this.logBytes); $('.js-truncated-info-size').html(`${size}`); this.$truncatedInfo.removeClass('hidden'); - this.initAffixTruncatedInfo(); } else { this.$truncatedInfo.addClass('hidden'); } - this.checkAutoscroll(); - if (!log.complete) { + this.toggleScrollAnimation(true); + Build.timeout = setTimeout(() => { - this.invokeBuildTrace(); + //eslint-disable-next-line + this.getBuildTrace() + .then(() => this.scrollToBottom()); }, 4000); } else { this.$buildRefreshAnimation.remove(); + this.toggleScrollAnimation(false); } if (log.status !== this.buildStatus) { - let pageUrl = this.pageUrl; - - if (this.$autoScrollStatus.data('state') === 'enabled') { - pageUrl += DOWN_BUILD_TRACE; - } - - gl.utils.visitUrl(pageUrl); + gl.utils.visitUrl(this.pageUrl); } - }), - error: () => { + }) + .fail(() => { this.$buildRefreshAnimation.remove(); - return this.initScrollMonitor(); - }, - }); - }; - - Build.prototype.checkAutoscroll = function () { - if (this.$autoScrollStatus.data('state') === 'enabled') { - return $('html,body').scrollTop(this.$buildTrace.height()); - } - - // Handle a situation where user started new build - // but never scrolled a page - if (!this.$scrollTopBtn.is(':visible') && - !this.$scrollBottomBtn.is(':visible') && - !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { - this.$scrollBottomBtn.show(); - } - }; - - Build.prototype.initScrollButtonAffix = function () { - // Hide everything initially - this.$scrollTopBtn.hide(); - this.$scrollBottomBtn.hide(); - this.$autoScrollContainer.hide(); - }; - - // Page scroll listener to detect if user has scrolling page - // and handle following cases - // 1) User is at Top of Build Log; - // - Hide Top Arrow button - // - Show Bottom Arrow button - // - Disable Autoscroll and hide indicator (when build is running) - // 2) User is at Bottom of Build Log; - // - Show Top Arrow button - // - Hide Bottom Arrow button - // - Enable Autoscroll and show indicator (when build is running) - // 3) User is somewhere in middle of Build Log; - // - Show Top Arrow button - // - Show Bottom Arrow button - // - Disable Autoscroll and hide indicator (when build is running) - Build.prototype.initScrollMonitor = function () { - if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && - !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { - // User is somewhere in middle of Build Log - - this.$scrollTopBtn.show(); - - if (this.buildStatus === 'success' || this.buildStatus === 'failed') { // Check if Build is completed - this.$scrollBottomBtn.show(); - } else if (this.$buildRefreshAnimation.is(':visible') && - !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) { - this.$scrollBottomBtn.show(); - } else { - this.$scrollBottomBtn.hide(); - } - - // Hide Autoscroll Status Indicator - if (this.$scrollBottomBtn.is(':visible')) { - this.$autoScrollContainer.hide(); - this.$autoScrollStatusText.removeClass('animate'); - } else { - this.$autoScrollContainer.css({ - top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET, - }).show(); - this.$autoScrollStatusText.addClass('animate'); - } - } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && - !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { - // User is at Top of Build Log - - this.$scrollTopBtn.hide(); - this.$scrollBottomBtn.show(); - - this.$autoScrollContainer.hide(); - this.$autoScrollStatusText.removeClass('animate'); - } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && - gl.utils.isInViewport(this.$downBuildTrace.get(0))) || - (this.$buildRefreshAnimation.is(':visible') && - gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) { - // User is at Bottom of Build Log - - this.$scrollTopBtn.show(); - this.$scrollBottomBtn.hide(); - - // Show and Reposition Autoscroll Status Indicator - this.$autoScrollContainer.css({ - top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET, - }).show(); - this.$autoScrollStatusText.addClass('animate'); - } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && - gl.utils.isInViewport(this.$downBuildTrace.get(0))) { - // Build Log height is small - - this.$scrollTopBtn.hide(); - this.$scrollBottomBtn.hide(); - - // Hide Autoscroll Status Indicator - this.$autoScrollContainer.hide(); - this.$autoScrollStatusText.removeClass('animate'); - } - - if (this.buildStatus === 'running' || this.buildStatus === 'pending') { - // Check if Refresh Animation is in Viewport and enable Autoscroll, disable otherwise. - this.$autoScrollStatus.data( - 'state', - gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled', - ); - } + }); }; Build.prototype.shouldHideSidebarForViewport = function () { @@ -257,18 +238,23 @@ window.Build = (function () { }; Build.prototype.toggleSidebar = function (shouldHide) { - const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined; + const shouldShow = !shouldHide; - this.$buildScroll.toggleClass('sidebar-expanded', shouldShow) + this.$buildTrace + .toggleClass('sidebar-expanded', shouldShow) .toggleClass('sidebar-collapsed', shouldHide); - this.$truncatedInfo.toggleClass('sidebar-expanded', shouldShow) - .toggleClass('sidebar-collapsed', shouldHide); - this.$sidebar.toggleClass('right-sidebar-expanded', shouldShow) + this.$sidebar + .toggleClass('right-sidebar-expanded', shouldShow) .toggleClass('right-sidebar-collapsed', shouldHide); }; Build.prototype.sidebarOnResize = function () { this.toggleSidebar(this.shouldHideSidebarForViewport()); + this.verifyTopPosition(); + + if (this.$scrollContainer.getNiceScroll(0)) { + this.toggleScroll(); + } }; Build.prototype.sidebarOnClick = function () { @@ -301,24 +287,5 @@ window.Build = (function () { this.populateJobs(stage); }; - Build.prototype.stepTrace = function (e) { - e.preventDefault(); - - const $currentTarget = $(e.currentTarget); - $.scrollTo($currentTarget.attr('href'), { - offset: 0, - }); - }; - - Build.prototype.initAffixTruncatedInfo = function () { - const offsetTop = this.$buildTrace.offset().top; - - this.$truncatedInfo.affix({ - offset: { - top: offsetTop, - }, - }); - }; - return Build; })(); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 9e71c482d21bbdd22acfbcc4d11782e6addae7e7..53b25da18e572ac7852ab9f9a15f59e88e3a4bf7 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -124,7 +124,8 @@ import ShortcutsBlob from './shortcuts_blob'; case 'projects:merge_requests:index': case 'projects:issues:index': if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) { - new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests'); + const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests'); + filteredSearchManager.setup(); } Issuable.init(); new gl.IssuableBulkActions({ diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js index 5d48b8aacb23a19f27a48445770652c3fe9ffbbe..132b6fe698abe09637c887d34268e12994c5486e 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js +++ b/app/assets/javascripts/filtered_search/filtered_search_bundle.js @@ -2,9 +2,9 @@ import './dropdown_hint'; import './dropdown_non_user'; import './dropdown_user'; import './dropdown_utils'; +import './filtered_search_token_keys'; import './filtered_search_dropdown_manager'; import './filtered_search_dropdown'; import './filtered_search_manager'; -import './filtered_search_token_keys'; import './filtered_search_tokenizer'; import './filtered_search_visual_tokens'; diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 58f2b75bd50a4f841b0b52f8bc772d41a6aa5d24..3be889c684b37b4c7c218b33a0cbd0c583b0efc0 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -6,6 +6,7 @@ import eventHub from './event_hub'; class FilteredSearchManager { constructor(page) { + this.page = page; this.container = FilteredSearchContainer.container; this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInputForm = this.filteredSearchInput.form; @@ -17,16 +18,18 @@ class FilteredSearchManager { isLocalStorageAvailable: RecentSearchesService.isAvailable(), allowedKeys: this.filteredSearchTokenKeys.getKeys(), }); - const searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown'); - const projectPath = searchHistoryDropdownElement ? - searchHistoryDropdownElement.dataset.projectFullPath : 'project'; + this.searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown'); + const projectPath = this.searchHistoryDropdownElement ? + this.searchHistoryDropdownElement.dataset.projectFullPath : 'project'; let recentSearchesPagePrefix = 'issue-recent-searches'; - if (page === 'merge_requests') { + if (this.page === 'merge_requests') { recentSearchesPagePrefix = 'merge-request-recent-searches'; } const recentSearchesKey = `${projectPath}-${recentSearchesPagePrefix}`; this.recentSearchesService = new RecentSearchesService(recentSearchesKey); + } + setup() { // Fetch recent searches from localStorage this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch() .catch((error) => { @@ -47,12 +50,12 @@ class FilteredSearchManager { if (this.filteredSearchInput) { this.tokenizer = gl.FilteredSearchTokenizer; - this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, page); + this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, this.page); this.recentSearchesRoot = new RecentSearchesRoot( this.recentSearchesStore, this.recentSearchesService, - searchHistoryDropdownElement, + this.searchHistoryDropdownElement, ); this.recentSearchesRoot.init(); @@ -141,7 +144,9 @@ class FilteredSearchManager { if (e.keyCode === 8 || e.keyCode === 46) { const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); - if (this.filteredSearchInput.value === '' && lastVisualToken) { + const sanitizedTokenName = lastVisualToken && lastVisualToken.querySelector('.name').textContent.trim(); + const canEdit = sanitizedTokenName && this.canEdit && this.canEdit(sanitizedTokenName); + if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) { this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial(); gl.FilteredSearchVisualTokens.removeLastTokenPartial(); } @@ -240,8 +245,10 @@ class FilteredSearchManager { editToken(e) { const token = e.target.closest('.js-visual-token'); + const sanitizedTokenName = token.querySelector('.name').textContent.trim(); + const canEdit = this.canEdit && this.canEdit(sanitizedTokenName); - if (token) { + if (token && canEdit) { gl.FilteredSearchVisualTokens.editToken(token); this.tokenChange(); } @@ -391,7 +398,12 @@ class FilteredSearchManager { if (condition) { hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value); + const canEdit = this.canEdit && this.canEdit(condition.tokenKey); + gl.FilteredSearchVisualTokens.addFilterVisualToken( + condition.tokenKey, + condition.value, + canEdit, + ); } else { // Sanitize value since URL converts spaces into + // Replace before decode so that we know what was originally + versus the encoded + @@ -410,18 +422,27 @@ class FilteredSearchManager { } hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken(sanitizedKey, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`); + const canEdit = this.canEdit && this.canEdit(sanitizedKey); + gl.FilteredSearchVisualTokens.addFilterVisualToken( + sanitizedKey, + `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`, + canEdit, + ); } else if (!match && keyParam === 'assignee_id') { const id = parseInt(value, 10); if (usernameParams[id]) { hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', `@${usernameParams[id]}`); + const tokenName = 'assignee'; + const canEdit = this.canEdit && this.canEdit(tokenName); + gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit); } } else if (!match && keyParam === 'author_id') { const id = parseInt(value, 10); if (usernameParams[id]) { hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken('author', `@${usernameParams[id]}`); + const tokenName = 'author'; + const canEdit = this.canEdit && this.canEdit(tokenName); + gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit); } } else if (!match && keyParam === 'search') { hasFilteredSearch = true; @@ -516,6 +537,11 @@ class FilteredSearchManager { this.filteredSearchInput.dispatchEvent(new CustomEvent('input')); this.search(); } + + // eslint-disable-next-line class-methods-use-this + canEdit() { + return true; + } } window.gl = window.gl || {}; diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index f3003b86493ea324bd3c218429a7aca1ed6d0a74..bc1226f5879112350387ecf4f0ce6766af836d92 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -36,15 +36,22 @@ class FilteredSearchVisualTokens { } } - static createVisualTokenElementHTML() { + static createVisualTokenElementHTML(canEdit = true) { + let removeTokenMarkup = ''; + if (canEdit) { + removeTokenMarkup = ` + <div class="remove-token" role="button"> + <i class="fa fa-close"></i> + </div> + `; + } + return ` <div class="selectable" role="button"> <div class="name"></div> <div class="value-container"> <div class="value"></div> - <div class="remove-token" role="button"> - <i class="fa fa-close"></i> - </div> + ${removeTokenMarkup} </div> </div> `; @@ -84,13 +91,13 @@ class FilteredSearchVisualTokens { } } - static addVisualTokenElement(name, value, isSearchTerm) { + static addVisualTokenElement(name, value, isSearchTerm, canEdit) { const li = document.createElement('li'); li.classList.add('js-visual-token'); li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token'); if (value) { - li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); + li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(canEdit); FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); } else { li.innerHTML = '<div class="name"></div>'; @@ -114,20 +121,20 @@ class FilteredSearchVisualTokens { } } - static addFilterVisualToken(tokenName, tokenValue) { + static addFilterVisualToken(tokenName, tokenValue, canEdit) { const { lastVisualToken, isLastVisualTokenValid } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); const addVisualTokenElement = FilteredSearchVisualTokens.addVisualTokenElement; if (isLastVisualTokenValid) { - addVisualTokenElement(tokenName, tokenValue, false); + addVisualTokenElement(tokenName, tokenValue, false, canEdit); } else { const previousTokenName = lastVisualToken.querySelector('.name').innerText; const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); tokensContainer.removeChild(lastVisualToken); const value = tokenValue || tokenName; - addVisualTokenElement(previousTokenName, value, false); + addVisualTokenElement(previousTokenName, value, false, canEdit); } } diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 24c423dd01ef508551a6ed54ac3adbe0cbc7415b..d34561e5512af18363689c53af607e955f3cf2bb 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -468,8 +468,8 @@ GitLabDropdown = (function() { // Process the data to make sure rendered data // matches the correct layout - if (this.fullData && hasMultiSelect && this.options.processData) { - const inputValue = this.filterInput.val(); + const inputValue = this.filterInput.val(); + if (this.fullData && hasMultiSelect && this.options.processData && inputValue.length === 0) { this.options.processData.call(this.options, inputValue, this.filteredFullData(), this.parseData.bind(this)); } @@ -740,6 +740,12 @@ GitLabDropdown = (function() { $input.attr('id', this.options.inputId); } + if (this.options.multiSelect) { + Object.keys(selectedObject).forEach((attribute) => { + $input.attr(`data-${attribute}`, selectedObject[attribute]); + }); + } + if (this.options.inputMeta) { $input.attr('data-meta', selectedObject[this.options.inputMeta]); } diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js index 66f39122a663f4de230084e87fb5451f4dc6dfa7..973d6119158b94d576aaaaffda780d1324ef6a19 100644 --- a/app/assets/javascripts/lib/utils/notify.js +++ b/app/assets/javascripts/lib/utils/notify.js @@ -1,47 +1,48 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, consistent-return, prefer-arrow-callback, no-return-assign, object-shorthand, comma-dangle, no-param-reassign, max-len */ -(function() { - (function(w) { - var notificationGranted, notifyMe, notifyPermissions; - notificationGranted = function(message, opts, onclick) { - var notification; - notification = new Notification(message, opts); - setTimeout(function() { - return notification.close(); - // Hide the notification after X amount of seconds - }, 8000); - if (onclick) { - return notification.onclick = onclick; - } - }; - notifyPermissions = function() { - if ('Notification' in window) { - return Notification.requestPermission(); - } - }; - notifyMe = function(message, body, icon, onclick) { - var opts; - opts = { - body: body, - icon: icon - }; - // Let's check if the browser supports notifications - if (!('Notification' in window)) { +function notificationGranted(message, opts, onclick) { + var notification; + notification = new Notification(message, opts); + setTimeout(function() { + // Hide the notification after X amount of seconds + return notification.close(); + }, 8000); + + return notification.onclick = onclick || notification.close; +} - // do nothing - } else if (Notification.permission === 'granted') { - // If it's okay let's create a notification +function notifyPermissions() { + if ('Notification' in window) { + return Notification.requestPermission(); + } +} + +function notifyMe(message, body, icon, onclick) { + var opts; + opts = { + body: body, + icon: icon + }; + // Let's check if the browser supports notifications + if (!('Notification' in window)) { + // do nothing + } else if (Notification.permission === 'granted') { + // If it's okay let's create a notification + return notificationGranted(message, opts, onclick); + } else if (Notification.permission !== 'denied') { + return Notification.requestPermission(function(permission) { + // If the user accepts, let's create a notification + if (permission === 'granted') { return notificationGranted(message, opts, onclick); - } else if (Notification.permission !== 'denied') { - return Notification.requestPermission(function(permission) { - // If the user accepts, let's create a notification - if (permission === 'granted') { - return notificationGranted(message, opts, onclick); - } - }); } - }; - w.notify = notifyMe; - return w.notifyPermissions = notifyPermissions; - })(window); -}).call(window); + }); + } +} + +const notify = { + notificationGranted, + notifyPermissions, + notifyMe, +}; + +export default notify; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index f1b0740867199638c9ab823e61315c6a7e325247..573940979441ae64b553e2525f5c85c384dee821 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -42,3 +42,13 @@ export function formatRelevantDigits(number) { export function bytesToKiB(number) { return number / BYTES_IN_KIB; } + +/** + * Utility function that calculates MiB of the given bytes. + * + * @param {Number} number bytes + * @return {Number} MiB + */ +export function bytesToMiB(number) { + return number / (BYTES_IN_KIB * BYTES_IN_KIB); +} diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index f095897213066760b1d0efa28ec3900d6752bef6..1ac82b7e291504cb5b74d5b4978a8c486e76b890 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -56,7 +56,6 @@ import './lib/utils/animate'; import './lib/utils/bootstrap_linked_tabs'; import './lib/utils/common_utils'; import './lib/utils/datetime_utility'; -import './lib/utils/notify'; import './lib/utils/pretty_time'; import './lib/utils/text_utility'; import './lib/utils/url_utility'; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 22032d0f91414d1a3e2c9740dad39f21ce2ab023..894ed81b044c5a989222881a414579ee82ce61a7 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -285,7 +285,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; // Similar to `toggler_behavior` in the discussion tab const hash = window.gl.utils.getLocationHash(); const anchor = hash && $container.find(`[id="${hash}"]`); - if (anchor) { + if (anchor && anchor.length > 0) { const notesContent = anchor.closest('.notes_content'); const lineType = notesContent.hasClass('new') ? 'new' : 'old'; notes.toggleDiffNote({ diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index b0b1cfd6c8aa8794921a1b728dd7c7f823dcdfe2..0ca7cabfc5a2e7ad94b4578549354d75ffd0dbaf 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1,4 +1,10 @@ -/* eslint-disable no-restricted-properties, func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, camelcase, no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line, default-case, prefer-template, consistent-return, no-alert, no-return-assign, no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new, brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow, newline-per-chained-call, no-useless-escape */ +/* eslint-disable no-restricted-properties, func-names, space-before-function-paren, +no-var, prefer-rest-params, wrap-iife, no-use-before-define, camelcase, +no-unused-expressions, quotes, max-len, one-var, one-var-declaration-per-line, +default-case, prefer-template, consistent-return, no-alert, no-return-assign, +no-param-reassign, prefer-arrow-callback, no-else-return, comma-dangle, no-new, +brace-style, no-lonely-if, vars-on-top, no-unused-vars, no-sequences, no-shadow, +newline-per-chained-call, no-useless-escape */ /* global Flash */ /* global Autosave */ /* global ResolveService */ @@ -57,7 +63,7 @@ const normalizeNewlines = function(str) { this.updatedNotesTrackingMap = {}; this.last_fetched_at = last_fetched_at; this.noteable_url = document.URL; - this.notesCountBadge || (this.notesCountBadge = $(".issuable-details").find(".notes-tab .badge")); + this.notesCountBadge || (this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge')); this.basePollingInterval = 15000; this.maxPollingSteps = 4; this.flashErrors = []; @@ -87,61 +93,61 @@ const normalizeNewlines = function(str) { Notes.prototype.addBinding = function() { // Edit note link - $(document).on("click", ".js-note-edit", this.showEditForm.bind(this)); - $(document).on("click", ".note-edit-cancel", this.cancelEdit); + $(document).on('click', '.js-note-edit', this.showEditForm.bind(this)); + $(document).on('click', '.note-edit-cancel', this.cancelEdit); // Reopen and close actions for Issue/MR combined with note form submit - $(document).on("click", ".js-comment-submit-button", this.postComment); - $(document).on("click", ".js-comment-save-button", this.updateComment); - $(document).on("keyup input", ".js-note-text", this.updateTargetButtons); + $(document).on('click', '.js-comment-submit-button', this.postComment); + $(document).on('click', '.js-comment-save-button', this.updateComment); + $(document).on('keyup input', '.js-note-text', this.updateTargetButtons); // resolve a discussion $(document).on('click', '.js-comment-resolve-button', this.postComment); // remove a note (in general) - $(document).on("click", ".js-note-delete", this.removeNote); + $(document).on('click', '.js-note-delete', this.removeNote); // delete note attachment - $(document).on("click", ".js-note-attachment-delete", this.removeAttachment); + $(document).on('click', '.js-note-attachment-delete', this.removeAttachment); // reset main target form when clicking discard - $(document).on("click", ".js-note-discard", this.resetMainTargetForm); + $(document).on('click', '.js-note-discard', this.resetMainTargetForm); // update the file name when an attachment is selected - $(document).on("change", ".js-note-attachment-input", this.updateFormAttachment); + $(document).on('change', '.js-note-attachment-input', this.updateFormAttachment); // reply to diff/discussion notes - $(document).on("click", ".js-discussion-reply-button", this.onReplyToDiscussionNote); + $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); // add diff note - $(document).on("click", ".js-add-diff-note-button", this.onAddDiffNote); + $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote); // hide diff note form - $(document).on("click", ".js-close-discussion-note-form", this.cancelDiscussionForm); + $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); // toggle commit list - $(document).on("click", '.system-note-commit-list-toggler', this.toggleCommitList); + $(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList); // fetch notes when tab becomes visible - $(document).on("visibilitychange", this.visibilityChange); + $(document).on('visibilitychange', this.visibilityChange); // when issue status changes, we need to refresh data - $(document).on("issuable:change", this.refresh); + $(document).on('issuable:change', this.refresh); // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs. - $(document).on("ajax:success", ".js-main-target-form", this.addNote); - $(document).on("ajax:success", ".js-discussion-note-form", this.addDiscussionNote); - $(document).on("ajax:success", ".js-main-target-form", this.resetMainTargetForm); - $(document).on("ajax:complete", ".js-main-target-form", this.reenableTargetFormSubmitButton); + $(document).on('ajax:success', '.js-main-target-form', this.addNote); + $(document).on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote); + $(document).on('ajax:success', '.js-main-target-form', this.resetMainTargetForm); + $(document).on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton); // when a key is clicked on the notes - return $(document).on("keydown", ".js-note-text", this.keydownNoteText); + return $(document).on('keydown', '.js-note-text', this.keydownNoteText); }; Notes.prototype.cleanBinding = function() { - $(document).off("click", ".js-note-edit"); - $(document).off("click", ".note-edit-cancel"); - $(document).off("click", ".js-note-delete"); - $(document).off("click", ".js-note-attachment-delete"); - $(document).off("click", ".js-discussion-reply-button"); - $(document).off("click", ".js-add-diff-note-button"); - $(document).off("visibilitychange"); - $(document).off("keyup input", ".js-note-text"); - $(document).off("click", ".js-note-target-reopen"); - $(document).off("click", ".js-note-target-close"); - $(document).off("click", ".js-note-discard"); - $(document).off("keydown", ".js-note-text"); + $(document).off('click', '.js-note-edit'); + $(document).off('click', '.note-edit-cancel'); + $(document).off('click', '.js-note-delete'); + $(document).off('click', '.js-note-attachment-delete'); + $(document).off('click', '.js-discussion-reply-button'); + $(document).off('click', '.js-add-diff-note-button'); + $(document).off('visibilitychange'); + $(document).off('keyup input', '.js-note-text'); + $(document).off('click', '.js-note-target-reopen'); + $(document).off('click', '.js-note-target-close'); + $(document).off('click', '.js-note-discard'); + $(document).off('keydown', '.js-note-text'); $(document).off('click', '.js-comment-resolve-button'); - $(document).off("click", '.system-note-commit-list-toggler'); - $(document).off("ajax:success", ".js-main-target-form"); - $(document).off("ajax:success", ".js-discussion-note-form"); - $(document).off("ajax:complete", ".js-main-target-form"); + $(document).off('click', '.system-note-commit-list-toggler'); + $(document).off('ajax:success', '.js-main-target-form'); + $(document).off('ajax:success', '.js-discussion-note-form'); + $(document).off('ajax:complete', '.js-main-target-form'); }; Notes.initCommentTypeToggle = function (form) { @@ -231,8 +237,8 @@ const normalizeNewlines = function(str) { this.refreshing = true; return $.ajax({ url: this.notes_url, - headers: { "X-Last-Fetched-At": this.last_fetched_at }, - dataType: "json", + headers: { 'X-Last-Fetched-At': this.last_fetched_at }, + dataType: 'json', success: (function(_this) { return function(data) { var notes; @@ -303,7 +309,7 @@ const normalizeNewlines = function(str) { */ Notes.prototype.renderNote = function(noteEntity, $form, $notesList = $('.main-notes-list')) { - if (noteEntity.discussion_html != null) { + if (noteEntity.discussion_html) { return this.renderDiscussionNote(noteEntity, $form); } @@ -368,8 +374,8 @@ const normalizeNewlines = function(str) { return; } this.note_ids.push(noteEntity.id); - form = $form || $(".js-discussion-note-form[data-discussion-id='" + noteEntity.discussion_id + "']"); - row = form.closest("tr"); + form = $form || $(`.js-discussion-note-form[data-discussion-id="${noteEntity.discussion_id}"]`); + row = form.closest('tr'); lineType = this.isParallelView() ? form.find('#line_type').val() : 'old'; diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line'); // is this the first note of discussion? @@ -386,7 +392,7 @@ const normalizeNewlines = function(str) { row.after($discussion); } else { // Merge new discussion HTML in - var $notes = $discussion.find('.notes[data-discussion-id="' + noteEntity.discussion_id + '"]'); + var $notes = $discussion.find(`.notes[data-discussion-id="${noteEntity.discussion_id}"]`); var contentContainerClass = '.' + $notes.closest('.notes_content') .attr('class') .split(' ') @@ -397,7 +403,7 @@ const normalizeNewlines = function(str) { } // Init discussion on 'Discussion' page if it is merge request page const page = $('body').attr('data-page'); - if ((page && page.indexOf('projects:merge_request') === 0) || !noteEntity.diff_discussion_html) { + if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) { Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); } } else { @@ -450,13 +456,13 @@ const normalizeNewlines = function(str) { Notes.prototype.resetMainTargetForm = function(e) { var form; - form = $(".js-main-target-form"); + form = $('.js-main-target-form'); // remove validation errors - form.find(".js-errors").remove(); + form.find('.js-errors').remove(); // reset text and preview - form.find(".js-md-write-button").click(); - form.find(".js-note-text").val("").trigger("input"); - form.find(".js-note-text").data("autosave").reset(); + form.find('.js-md-write-button').click(); + form.find('.js-note-text').val('').trigger('input'); + form.find('.js-note-text').data('autosave').reset(); var event = document.createEvent('Event'); event.initEvent('autosize:update', true, false); @@ -467,8 +473,8 @@ const normalizeNewlines = function(str) { Notes.prototype.reenableTargetFormSubmitButton = function() { var form; - form = $(".js-main-target-form"); - return form.find(".js-note-text").trigger("input"); + form = $('.js-main-target-form'); + return form.find('.js-note-text').trigger('input'); }; /* @@ -480,18 +486,18 @@ const normalizeNewlines = function(str) { Notes.prototype.setupMainTargetNoteForm = function() { var form; // find the form - form = $(".js-new-note-form"); + form = $('.js-new-note-form'); // Set a global clone of the form for later cloning this.formClone = form.clone(); // show the form this.setupNoteForm(form); // fix classes - form.removeClass("js-new-note-form"); - form.addClass("js-main-target-form"); - form.find("#note_line_code").remove(); - form.find("#note_position").remove(); - form.find("#note_type").val(''); - form.find("#in_reply_to_discussion_id").remove(); + form.removeClass('js-new-note-form'); + form.addClass('js-main-target-form'); + form.find('#note_line_code').remove(); + form.find('#note_position').remove(); + form.find('#note_type').val(''); + form.find('#in_reply_to_discussion_id').remove(); form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove(); this.parentTimeline = form.parents('.timeline'); @@ -512,20 +518,20 @@ const normalizeNewlines = function(str) { Notes.prototype.setupNoteForm = function(form) { var textarea, key; new gl.GLForm(form, this.enableGFM); - textarea = form.find(".js-note-text"); + textarea = form.find('.js-note-text'); key = [ - "Note", - form.find("#note_noteable_type").val(), - form.find("#note_noteable_id").val(), - form.find("#note_commit_id").val(), - form.find("#note_type").val(), - form.find("#in_reply_to_discussion_id").val(), + 'Note', + form.find('#note_noteable_type').val(), + form.find('#note_noteable_id').val(), + form.find('#note_commit_id').val(), + form.find('#note_type').val(), + form.find('#in_reply_to_discussion_id').val(), // LegacyDiffNote - form.find("#note_line_code").val(), + form.find('#note_line_code').val(), // DiffNote - form.find("#note_position").val() + form.find('#note_position').val() ]; return new Autosave(textarea, key); }; @@ -670,7 +676,8 @@ const normalizeNewlines = function(str) { const $newNote = $(this.updatedNotesTrackingMap[noteId].html); $note.replaceWith($newNote); this.setupNewNote($newNote); - this.updatedNotesTrackingMap[noteId] = null; + // Now that we have taken care of the update, clear it out + delete this.updatedNotesTrackingMap[noteId]; } else { $note.find('.js-finish-edit-warning').hide(); @@ -722,14 +729,14 @@ const normalizeNewlines = function(str) { lineHolder = $(e.currentTarget).closest('.notes[data-discussion-id]') .closest('.notes_holder') .prev('.line_holder'); - $(".note[id='" + noteElId + "']").each((function(_this) { + $(`.note[id="${noteElId}"]`).each((function(_this) { // A same note appears in the "Discussion" and in the "Changes" tab, we have - // to remove all. Using $(".note[id='noteId']") ensure we get all the notes, - // where $("#noteId") would return only one. + // to remove all. Using $('.note[id='noteId']') ensure we get all the notes, + // where $('#noteId') would return only one. return function(i, el) { var $note, $notes; $note = $(el); - $notes = $note.closest(".discussion-notes"); + $notes = $note.closest('.discussion-notes'); if (typeof gl.diffNotesCompileComponents !== 'undefined') { if (gl.diffNoteApps[noteElId]) { @@ -740,11 +747,11 @@ const normalizeNewlines = function(str) { $note.remove(); // check if this is the last note for this line - if ($notes.find(".note").length === 0) { - var notesTr = $notes.closest("tr"); + if ($notes.find('.note').length === 0) { + var notesTr = $notes.closest('tr'); // "Discussions" tab - $notes.closest(".timeline-entry").remove(); + $notes.closest('.timeline-entry').remove(); // The notes tr can contain multiple lists of notes, like on the parallel diff if (notesTr.find('.discussion-notes').length > 1) { @@ -768,11 +775,11 @@ const normalizeNewlines = function(str) { */ Notes.prototype.removeAttachment = function() { - const $note = $(this).closest(".note"); - $note.find(".note-attachment").remove(); - $note.find(".note-body > .note-text").show(); - $note.find(".note-header").show(); - return $note.find(".current-note-edit-form").remove(); + const $note = $(this).closest('.note'); + $note.find('.note-attachment').remove(); + $note.find('.note-body > .note-text').show(); + $note.find('.note-header').show(); + return $note.find('.current-note-edit-form').remove(); }; /* @@ -788,7 +795,7 @@ const normalizeNewlines = function(str) { Notes.prototype.replyToDiscussionNote = function(target) { var form, replyLink; form = this.cleanForm(this.formClone.clone()); - replyLink = $(target).closest(".js-discussion-reply-button"); + replyLink = $(target).closest('.js-discussion-reply-button'); // insert the form after the button replyLink .closest('.discussion-reply-holder') @@ -808,26 +815,26 @@ const normalizeNewlines = function(str) { Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) { // setup note target - var discussionID = dataHolder.data("discussionId"); + var discussionID = dataHolder.data('discussionId'); if (discussionID) { - form.attr("data-discussion-id", discussionID); - form.find("#in_reply_to_discussion_id").val(discussionID); + form.attr('data-discussion-id', discussionID); + form.find('#in_reply_to_discussion_id').val(discussionID); } - form.attr("data-line-code", dataHolder.data("lineCode")); - form.find("#line_type").val(dataHolder.data("lineType")); + form.attr('data-line-code', dataHolder.data('lineCode')); + form.find('#line_type').val(dataHolder.data('lineType')); - form.find("#note_noteable_type").val(dataHolder.data("noteableType")); - form.find("#note_noteable_id").val(dataHolder.data("noteableId")); - form.find("#note_commit_id").val(dataHolder.data("commitId")); - form.find("#note_type").val(dataHolder.data("noteType")); + form.find('#note_noteable_type').val(dataHolder.data('noteableType')); + form.find('#note_noteable_id').val(dataHolder.data('noteableId')); + form.find('#note_commit_id').val(dataHolder.data('commitId')); + form.find('#note_type').val(dataHolder.data('noteType')); // LegacyDiffNote - form.find("#note_line_code").val(dataHolder.data("lineCode")); + form.find('#note_line_code').val(dataHolder.data('lineCode')); // DiffNote - form.find("#note_position").val(dataHolder.attr("data-position")); + form.find('#note_position').val(dataHolder.attr('data-position')); form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text')); form.find('.js-note-target-close').remove(); @@ -836,7 +843,7 @@ const normalizeNewlines = function(str) { form .removeClass('js-main-target-form') - .addClass("discussion-form js-discussion-note-form"); + .addClass('discussion-form js-discussion-note-form'); if (typeof gl.diffNotesCompileComponents !== 'undefined') { var $commentBtn = form.find('comment-and-resolve-btn'); @@ -845,7 +852,7 @@ const normalizeNewlines = function(str) { gl.diffNotesCompileComponents(); } - form.find(".js-note-text").focus(); + form.find('.js-note-text').focus(); form .find('.js-comment-resolve-button') .attr('data-discussion-id', discussionID); @@ -878,21 +885,21 @@ const normalizeNewlines = function(str) { }) { var $link, addForm, hasNotes, newForm, noteForm, replyButton, row, rowCssToAdd, targetContent, isDiffCommentAvatar; $link = $(target); - row = $link.closest("tr"); + row = $link.closest('tr'); const nextRow = row.next(); let targetRow = row; if (nextRow.is('.notes_holder')) { targetRow = nextRow; } - hasNotes = targetRow.is(".notes_holder"); + hasNotes = nextRow.is('.notes_holder'); addForm = false; let lineTypeSelector = ''; - rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line\" colspan=\"2\"></td><td class=\"notes_content\"><div class=\"content\"></div></td></tr>"; + rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line" colspan="2"></td><td class="notes_content"><div class="content"></div></td></tr>'; // In parallel view, look inside the correct left/right pane if (this.isParallelView()) { lineTypeSelector = `.${lineType}`; - rowCssToAdd = "<tr class=\"notes_holder js-temp-notes-holder\"><td class=\"notes_line old\"></td><td class=\"notes_content parallel old\"><div class=\"content\"></div></td><td class=\"notes_line new\"></td><td class=\"notes_content parallel new\"><div class=\"content\"></div></td></tr>"; + rowCssToAdd = '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>'; } const notesContentSelector = `.notes_content${lineTypeSelector} .content`; let notesContent = targetRow.find(notesContentSelector); @@ -902,12 +909,12 @@ const normalizeNewlines = function(str) { notesContent = targetRow.find(notesContentSelector); if (notesContent.length) { notesContent.show(); - replyButton = notesContent.find(".js-discussion-reply-button:visible"); + replyButton = notesContent.find('.js-discussion-reply-button:visible'); if (replyButton.length) { this.replyToDiscussionNote(replyButton[0]); } else { // In parallel view, the form may not be present in one of the panes - noteForm = notesContent.find(".js-discussion-note-form"); + noteForm = notesContent.find('.js-discussion-note-form'); if (noteForm.length === 0) { addForm = true; } @@ -945,15 +952,15 @@ const normalizeNewlines = function(str) { Notes.prototype.removeDiscussionNoteForm = function(form) { var glForm, row; - row = form.closest("tr"); + row = form.closest('tr'); glForm = form.data('gl-form'); glForm.destroy(); - form.find(".js-note-text").data("autosave").reset(); + form.find('.js-note-text').data('autosave').reset(); // show the reply button (will only work for replies) form .prev('.discussion-reply-holder') .show(); - if (row.is(".js-temp-notes-holder")) { + if (row.is('.js-temp-notes-holder')) { // remove temporary row for diff lines return row.remove(); } else { @@ -965,7 +972,7 @@ const normalizeNewlines = function(str) { Notes.prototype.cancelDiscussionForm = function(e) { var form; e.preventDefault(); - form = $(e.target).closest(".js-discussion-note-form"); + form = $(e.target).closest('.js-discussion-note-form'); return this.removeDiscussionNoteForm(form); }; @@ -977,10 +984,10 @@ const normalizeNewlines = function(str) { Notes.prototype.updateFormAttachment = function() { var filename, form; - form = $(this).closest("form"); + form = $(this).closest('form'); // get only the basename - filename = $(this).val().replace(/^.*[\\\/]/, ""); - return form.find(".js-attachment-filename").text(filename); + filename = $(this).val().replace(/^.*[\\\/]/, ''); + return form.find('.js-attachment-filename').text(filename); }; /* @@ -1212,7 +1219,7 @@ const normalizeNewlines = function(str) { `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry"> <div class="timeline-entry-inner"> <div class="timeline-icon"> - <a href="/${currentUsername}"><span class="dummy-avatar"></span></a> + <a href="/${currentUsername}"><span class="avatar dummy-avatar"></span></a> </div> <div class="timeline-content ${discussionClass}"> <div class="note-header"> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 14c98847d933555c28bd3756c70926bf274e4871..77cbaeb43eff80020af294bfe1ce2b219638c414 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,68 +1,32 @@ <script> - /* global Flash */ - import Visibility from 'visibilityjs'; - import Poll from '../../../lib/utils/poll'; - import PipelineService from '../../services/pipeline_service'; - import PipelineStore from '../../stores/pipeline_store'; import stageColumnComponent from './stage_column_component.vue'; import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; import '../../../flash'; export default { + props: { + isLoading: { + type: Boolean, + required: true, + }, + pipeline: { + type: Object, + required: true, + }, + }, + components: { stageColumnComponent, loadingIcon, }, - data() { - const DOMdata = document.getElementById('js-pipeline-graph-vue').dataset; - const store = new PipelineStore(); - - return { - isLoading: false, - endpoint: DOMdata.endpoint, - store, - state: store.state, - }; - }, - - created() { - this.service = new PipelineService(this.endpoint); - - const poll = new Poll({ - resource: this.service, - method: 'getPipeline', - successCallback: this.successCallback, - errorCallback: this.errorCallback, - }); - - if (!Visibility.hidden()) { - this.isLoading = true; - poll.makeRequest(); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - poll.restart(); - } else { - poll.stop(); - } - }); + computed: { + graph() { + return this.pipeline.details && this.pipeline.details.stages; + }, }, methods: { - successCallback(response) { - const data = response.json(); - - this.isLoading = false; - this.store.storeGraph(data.details.stages); - }, - - errorCallback() { - this.isLoading = false; - return new Flash('An error occurred while fetching the pipeline.'); - }, - capitalizeStageName(name) { return name.charAt(0).toUpperCase() + name.slice(1); }, @@ -101,7 +65,7 @@ v-if="!isLoading" class="stage-column-list"> <stage-column-component - v-for="(stage, index) in state.graph" + v-for="(stage, index) in graph" :title="capitalizeStageName(stage.name)" :jobs="stage.groups" :key="stage.name" diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.js b/app/assets/javascripts/pipelines/components/pipeline_url.js deleted file mode 100644 index 7cd2e0f936602951f48e704a96fc6d3a498c9bcc..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/pipelines/components/pipeline_url.js +++ /dev/null @@ -1,56 +0,0 @@ -import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; - -export default { - props: [ - 'pipeline', - ], - computed: { - user() { - return !!this.pipeline.user; - }, - }, - components: { - userAvatarLink, - }, - template: ` - <td> - <a - :href="pipeline.path" - class="js-pipeline-url-link"> - <span class="pipeline-id">#{{pipeline.id}}</span> - </a> - <span>by</span> - <user-avatar-link - v-if="user" - class="js-pipeline-url-user" - :link-href="pipeline.user.web_url" - :img-src="pipeline.user.avatar_url" - :tooltip-text="pipeline.user.name" - /> - <span - v-if="!user" - class="js-pipeline-url-api api"> - API - </span> - <span - v-if="pipeline.flags.latest" - class="js-pipeline-url-lastest label label-success has-tooltip" - title="Latest pipeline for this branch" - data-original-title="Latest pipeline for this branch"> - latest - </span> - <span - v-if="pipeline.flags.yaml_errors" - class="js-pipeline-url-yaml label label-danger has-tooltip" - :title="pipeline.yaml_errors" - :data-original-title="pipeline.yaml_errors"> - yaml invalid - </span> - <span - v-if="pipeline.flags.stuck" - class="js-pipeline-url-stuck label label-warning"> - stuck - </span> - </td> - `, -}; diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue new file mode 100644 index 0000000000000000000000000000000000000000..b8457fae967a585b8e4665eeaf2337b4286495ad --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -0,0 +1,65 @@ +<script> +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import tooltipMixin from '../../vue_shared/mixins/tooltip'; + +export default { + props: { + pipeline: { + type: Object, + required: true, + }, + }, + components: { + userAvatarLink, + }, + mixins: [ + tooltipMixin, + ], + computed: { + user() { + return this.pipeline.user; + }, + }, +}; +</script> +<template> + <td> + <a + :href="pipeline.path" + class="js-pipeline-url-link"> + <span class="pipeline-id">#{{pipeline.id}}</span> + </a> + <span>by</span> + <user-avatar-link + v-if="user" + class="js-pipeline-url-user" + :link-href="pipeline.user.web_url" + :img-src="pipeline.user.avatar_url" + :tooltip-text="pipeline.user.name" + /> + <span + v-if="!user" + class="js-pipeline-url-api api"> + API + </span> + <span + v-if="pipeline.flags.latest" + class="js-pipeline-url-lastest label label-success" + title="Latest pipeline for this branch" + ref="tooltip"> + latest + </span> + <span + v-if="pipeline.flags.yaml_errors" + class="js-pipeline-url-yaml label label-danger" + :title="pipeline.yaml_errors" + ref="tooltip"> + yaml invalid + </span> + <span + v-if="pipeline.flags.stuck" + class="js-pipeline-url-stuck label label-warning"> + stuck + </span> + </td> +</template> diff --git a/app/assets/javascripts/pipelines/graph_bundle.js b/app/assets/javascripts/pipelines/graph_bundle.js deleted file mode 100644 index b7a6b5d847977d0ef155c5d370403b543242757b..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/pipelines/graph_bundle.js +++ /dev/null @@ -1,10 +0,0 @@ -import Vue from 'vue'; -import pipelineGraph from './components/graph/graph_component.vue'; - -document.addEventListener('DOMContentLoaded', () => new Vue({ - el: '#js-pipeline-graph-vue', - components: { - pipelineGraph, - }, - render: createElement => createElement('pipeline-graph'), -})); diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js new file mode 100644 index 0000000000000000000000000000000000000000..5aab25e03489cdcb02565e1fc5bfc56ed64d180c --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import PipelinesMediator from './pipeline_details_mediatior'; +import pipelineGraph from './components/graph/graph_component.vue'; + +document.addEventListener('DOMContentLoaded', () => { + const dataset = document.querySelector('.js-pipeline-details-vue').dataset; + + const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); + + mediator.fetchPipeline(); + + const pipelineGraphApp = new Vue({ + el: '#js-pipeline-graph-vue', + data() { + return { + mediator, + }; + }, + components: { + pipelineGraph, + }, + render(createElement) { + return createElement('pipeline-graph', { + props: { + isLoading: this.mediator.state.isLoading, + pipeline: this.mediator.store.state.pipeline, + }, + }); + }, + }); + + return pipelineGraphApp; +}); diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediatior.js b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js new file mode 100644 index 0000000000000000000000000000000000000000..b9a6d5ca5fca85df218c8615bddc4a5c5bf860e1 --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js @@ -0,0 +1,51 @@ +/* global Flash */ + +import Visibility from 'visibilityjs'; +import Poll from '../lib/utils/poll'; +import PipelineStore from './stores/pipeline_store'; +import PipelineService from './services/pipeline_service'; + +export default class pipelinesMediator { + constructor(options = {}) { + this.options = options; + this.store = new PipelineStore(); + this.service = new PipelineService(options.endpoint); + + this.state = {}; + this.state.isLoading = false; + } + + fetchPipeline() { + this.poll = new Poll({ + resource: this.service, + method: 'getPipeline', + successCallback: this.successCallback.bind(this), + errorCallback: this.errorCallback.bind(this), + }); + + if (!Visibility.hidden()) { + this.state.isLoading = true; + this.poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + } + + successCallback(response) { + const data = response.json(); + + this.state.isLoading = false; + this.store.storePipeline(data); + } + + errorCallback() { + this.state.isLoading = false; + return new Flash('An error occurred while fetching the pipeline.'); + } +} diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js index 86ab50d8f1e9742196033a6365141bf68565ef0b..052e34a8aef8a94f409e83fef874dcd17eac9f0a 100644 --- a/app/assets/javascripts/pipelines/stores/pipeline_store.js +++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js @@ -2,10 +2,10 @@ export default class PipelineStore { constructor() { this.state = {}; - this.state.graph = []; + this.state.pipeline = {}; } - storeGraph(graph = []) { - this.state.graph = graph; + storePipeline(pipeline = {}) { + this.state.pipeline = pipeline; } } diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 0ff0a3b6cc425b1358c7cf2b634110624a6281b9..9896b88d4875adbc53a87694b89d2bacc145cca8 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -51,6 +51,9 @@ import Api from './api'; this.groupId = $(select).data('group-id'); this.includeGroups = $(select).data('include-groups'); this.orderBy = $(select).data('order-by') || 'id'; + this.withIssuesEnabled = $(select).data('with-issues-enabled'); + this.withMergeRequestsEnabled = $(select).data('with-merge-requests-enabled'); + placeholder = "Search for project"; if (this.includeGroups) { placeholder += " or group"; @@ -84,7 +87,11 @@ import Api from './api'; if (_this.groupId) { return Api.groupProjects(_this.groupId, query.term, projectsCallback); } else { - return Api.projects(query.term, { order_by: _this.orderBy }, projectsCallback); + return Api.projects(query.term, { + order_by: _this.orderBy, + with_issues_enabled: _this.withIssuesEnabled, + with_merge_requests_enabled: _this.withMergeRequestsEnabled + }, projectsCallback); } }; })(this), diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index aea3592c6badc9010b60dfe38477cbc323a2e266..ec45253e50bddce19c20d0c0d30232ad86c267e9 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -35,6 +35,7 @@ function UsersSelect(currentUser, els) { options.showCurrentUser = $dropdown.data('current-user'); options.todoFilter = $dropdown.data('todo-filter'); options.todoStateFilter = $dropdown.data('todo-state-filter'); + options.perPage = $dropdown.data('per-page'); showNullUser = $dropdown.data('null-user'); defaultNullUser = $dropdown.data('null-user-default'); showMenuAbove = $dropdown.data('showMenuAbove'); @@ -214,7 +215,36 @@ function UsersSelect(currentUser, els) { glDropdown.options.processData(term, users, callback); }.bind(this)); }, - processData: function(term, users, callback) { + processData: function(term, data, callback) { + let users = data; + + // Only show assigned user list when there is no search term + if ($dropdown.hasClass('js-multiselect') && term.length === 0) { + const selectedInputs = getSelectedUserInputs(); + + // Potential duplicate entries when dealing with issue board + // because issue board is also managed by vue + const selectedUsers = _.uniq(selectedInputs, false, a => a.value) + .filter((input) => { + const userId = parseInt(input.value, 10); + const inUsersArray = users.find(u => u.id === userId); + + return !inUsersArray && userId !== 0; + }) + .map((input) => { + const userId = parseInt(input.value, 10); + const { avatarUrl, avatar_url, name, username } = input.dataset; + return { + avatar_url: avatarUrl || avatar_url, + id: userId, + name, + username, + }; + }); + + users = data.concat(selectedUsers); + } + let anyUser; let index; let j; @@ -645,7 +675,7 @@ UsersSelect.prototype.users = function(query, options, callback) { url: url, data: { search: query, - per_page: 20, + per_page: options.perPage || 20, active: true, project_id: options.projectId || null, group_id: options.groupId || null, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js index 486b13e60af178a8a03a25c38813e5735f01e747..8155218681c8e6366083f2d0003e0e3dc3c0d544 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_memory_usage.js @@ -1,4 +1,6 @@ import statusCodes from '~/lib/utils/http_status'; +import { bytesToMiB } from '~/lib/utils/number_utils'; + import MemoryGraph from '../../vue_shared/components/memory_graph'; import MRWidgetService from '../services/mr_widget_service'; @@ -9,8 +11,8 @@ export default { }, data() { return { - // memoryFrom: 0, - // memoryTo: 0, + memoryFrom: 0, + memoryTo: 0, memoryMetrics: [], deploymentTime: 0, hasMetrics: false, @@ -35,18 +37,38 @@ export default { shouldShowMetricsUnavailable() { return !this.loadingMetrics && !this.hasMetrics && !this.loadFailed; }, + memoryChangeType() { + const memoryTo = Number(this.memoryTo); + const memoryFrom = Number(this.memoryFrom); + + if (memoryTo > memoryFrom) { + return 'increased'; + } else if (memoryTo < memoryFrom) { + return 'decreased'; + } + + return 'unchanged'; + }, }, methods: { + getMegabytes(bytesString) { + const valueInBytes = Number(bytesString).toFixed(2); + return (bytesToMiB(valueInBytes)).toFixed(2); + }, computeGraphData(metrics, deploymentTime) { this.loadingMetrics = false; - const { memory_values } = metrics; - // if (memory_previous.length > 0) { - // this.memoryFrom = Number(memory_previous[0].value[1]).toFixed(2); - // } - // - // if (memory_current.length > 0) { - // this.memoryTo = Number(memory_current[0].value[1]).toFixed(2); - // } + const { memory_before, memory_after, memory_values } = metrics; + + // Both `memory_before` and `memory_after` objects + // have peculiar structure where accessing only a specific + // index yeilds correct value that we can use to show memory delta. + if (memory_before.length > 0) { + this.memoryFrom = this.getMegabytes(memory_before[0].value[1]); + } + + if (memory_after.length > 0) { + this.memoryTo = this.getMegabytes(memory_after[0].value[1]); + } if (memory_values.length > 0) { this.hasMetrics = true; @@ -102,7 +124,7 @@ export default { <p v-if="shouldShowMemoryGraph" class="usage-info js-usage-info"> - Deployment memory usage: + Memory usage <b>{{memoryChangeType}}</b> from {{memoryFrom}}MB to {{memoryTo}}MB </p> <p v-if="shouldShowLoadFailure" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js index d866d4e94b07517088187749ac344bc9b2fee718..fcd4fdaf09f64a97dd1e74939c52a2677e3980ae 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_ready_to_merge.js @@ -13,7 +13,7 @@ export default { }, data() { return { - removeSourceBranch: true, + removeSourceBranch: this.mr.shouldRemoveSourceBranch, mergeWhenBuildSucceeds: false, useCommitMessageWithDescription: false, setToMergeWhenPipelineSucceeds: false, @@ -69,6 +69,9 @@ export default { || this.isMakingRequest || this.mr.preventMerge); }, + isRemoveSourceBranchButtonDisabled() { + return this.isMergeButtonDisabled || !this.mr.canRemoveSourceBranch; + }, shouldShowSquashBeforeMerge() { const { commitsCount, enableSquashBeforeMerge } = this.mr; return enableSquashBeforeMerge && commitsCount > 1; @@ -252,8 +255,9 @@ export default { <template v-if="isMergeAllowed()"> <label class="spacing"> <input + id="remove-source-branch-input" v-model="removeSourceBranch" - :disabled="isMergeButtonDisabled" + :disabled="isRemoveSourceBranchButtonDisabled" type="checkbox"/> Remove source branch </label> diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index bfe30ee4c08cd0947d3a285f66b790d27d1de1bb..fe5e1bbb55ca5d8e5f01e75833f8ee167bcba7a8 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -41,3 +41,4 @@ export { default as getStateKey } from './stores/get_state_key'; export { default as mrWidgetOptions } from './mr_widget_options'; export { default as stateMaps } from './stores/state_maps'; export { default as SquashBeforeMerge } from './components/states/mr_widget_squash_before_merge'; +export { default as notify } from '../lib/utils/notify'; diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index cd65ac069c5c4324498606826c503f4dac8665de..43ef468c303a578b3da50934ec8c9bd81ad2bcdf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -4,6 +4,8 @@ import { } from './dependencies'; document.addEventListener('DOMContentLoaded', () => { + gl.mrWidgetData.gitlabLogo = gon.gitlab_logo; + const vm = new Vue(mrWidgetOptions); window.gl.mrWidget = { diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js index 99600b6664e69e15a2e1b48b6bc1a5e118dd4797..2339a00ddd03384b3a3534dee903deb5247ef5b2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -29,6 +29,7 @@ import { eventHub, stateMaps, SquashBeforeMerge, + notify, } from './dependencies'; export default { @@ -77,8 +78,10 @@ export default { this.service.checkStatus() .then(res => res.json()) .then((res) => { + this.handleNotification(res); this.mr.setData(res); this.setFavicon(); + if (cb) { cb.call(null, res); } @@ -136,6 +139,15 @@ export default { new Flash('Something went wrong. Please try again.'); // eslint-disable-line }); }, + handleNotification(data) { + if (data.ci_status === this.mr.ciStatus) return; + + const label = data.pipeline.details.status.label; + const title = `Pipeline ${label}`; + const message = `Pipeline ${label} for "${data.title}"`; + + notify.notifyMe(title, message, this.mr.gitlabLogo); + }, resumePolling() { this.pollingInterval.resume(); }, diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index c07bd25e6fd954a39b1ea1ffef388e2e81a704a5..69bc143628456b8a2cb19d29f37174aca3077547 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -5,6 +5,8 @@ export default class MergeRequestStore { constructor(data) { this.sha = data.diff_head_sha; + this.gitlabLogo = data.gitlabLogo; + this.setData(data); } @@ -50,7 +52,7 @@ export default class MergeRequestStore { this.cancelAutoMergePath = data.cancel_merge_when_pipeline_succeeds_path; this.removeWIPPath = data.remove_wip_path; this.sourceBranchRemoved = !data.source_branch_exists; - this.shouldRemoveSourceBranch = (data.merge_params || {}).should_remove_source_branch || false; + this.shouldRemoveSourceBranch = data.remove_source_branch || false; this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false; this.mergeWhenPipelineSucceeds = data.merge_when_pipeline_succeeds || false; this.mergePath = data.merge_path; diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue new file mode 100644 index 0000000000000000000000000000000000000000..fd0dcd716d6b97cc7adb9ee51e8e6dd7b992a77d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -0,0 +1,122 @@ +<script> +import ciIconBadge from './ci_badge_link.vue'; +import timeagoTooltip from './time_ago_tooltip.vue'; +import tooltipMixin from '../mixins/tooltip'; +import userAvatarLink from './user_avatar/user_avatar_link.vue'; + +/** + * Renders header component for job and pipeline page based on UI mockups + * + * Used in: + * - job show page + * - pipeline show page + */ +export default { + props: { + status: { + type: Object, + required: true, + }, + itemName: { + type: String, + required: true, + }, + itemId: { + type: Number, + required: true, + }, + time: { + type: String, + required: true, + }, + user: { + type: Object, + required: true, + }, + actions: { + type: Array, + required: false, + default: () => [], + }, + }, + + mixins: [ + tooltipMixin, + ], + + components: { + ciIconBadge, + timeagoTooltip, + userAvatarLink, + }, + + computed: { + userAvatarAltText() { + return `${this.user.name}'s avatar`; + }, + }, + + methods: { + onClickAction(action) { + this.$emit('postAction', action); + }, + }, +}; +</script> +<template> + <header class="page-content-header top-area"> + <section class="header-main-content"> + + <ci-icon-badge :status="status" /> + + <strong> + {{itemName}} #{{itemId}} + </strong> + + triggered + + <timeago-tooltip :time="time" /> + + by + + <user-avatar-link + :link-href="user.web_url" + :img-src="user.avatar_url" + :img-alt="userAvatarAltText" + :tooltip-text="user.name" + :img-size="24" + /> + + <a + :href="user.web_url" + :title="user.email" + class="js-user-link commit-committer-link" + ref="tooltip"> + {{user.name}} + </a> + </section> + + <section + class="header-action-button nav-controls" + v-if="actions.length"> + <template + v-for="action in actions"> + <a + v-if="action.type === 'link'" + :href="action.path" + :class="action.cssClass"> + {{action.label}} + </a> + + <button + v-else="action.type === 'button'" + @click="onClickAction(action)" + :class="action.cssClass" + type="button"> + {{action.label}} + </button> + + </template> + </section> + </header> +</template> diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js index 30d16e4ed3e040f33f512c78fd0929d19de15fd0..3283a6bcacc332ca7062c784804bcc0ebf058364 100644 --- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js @@ -4,7 +4,7 @@ import PipelinesActionsComponent from '../../pipelines/components/pipelines_acti import PipelinesArtifactsComponent from '../../pipelines/components/pipelines_artifacts'; import ciBadge from './ci_badge_link.vue'; import PipelinesStageComponent from '../../pipelines/components/stage.vue'; -import PipelinesUrlComponent from '../../pipelines/components/pipeline_url'; +import PipelinesUrlComponent from '../../pipelines/components/pipeline_url.vue'; import PipelinesTimeagoComponent from '../../pipelines/components/time_ago'; import CommitComponent from './commit'; diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue new file mode 100644 index 0000000000000000000000000000000000000000..af2b4c6786ed91b6ae040da88a1c7d56384f96fc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue @@ -0,0 +1,58 @@ +<script> +import tooltipMixin from '../mixins/tooltip'; +import timeagoMixin from '../mixins/timeago'; +import '../../lib/utils/datetime_utility'; + +/** + * Port of ruby helper time_ago_with_tooltip + */ + +export default { + props: { + time: { + type: String, + required: true, + }, + + tooltipPlacement: { + type: String, + required: false, + default: 'top', + }, + + shortFormat: { + type: Boolean, + required: false, + default: false, + }, + + cssClass: { + type: String, + required: false, + default: '', + }, + }, + + mixins: [ + tooltipMixin, + timeagoMixin, + ], + + computed: { + timeagoCssClass() { + return this.shortFormat ? 'js-short-timeago' : 'js-timeago'; + }, + }, +}; +</script> +<template> + <time + :class="[timeagoCssClass, cssClass]" + class="js-timeago js-timeago-render" + :title="tooltipTitle(time)" + :data-placement="tooltipPlacement" + data-container="body" + ref="tooltip"> + {{timeFormated(time)}} + </time> +</template> diff --git a/app/assets/javascripts/vue_shared/mixins/timeago.js b/app/assets/javascripts/vue_shared/mixins/timeago.js new file mode 100644 index 0000000000000000000000000000000000000000..20f63ab663c44aae50cbacabe71811972235dad8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/mixins/timeago.js @@ -0,0 +1,18 @@ +import '../../lib/utils/datetime_utility'; + +/** + * Mixin with time ago methods used in some vue components + */ +export default { + methods: { + timeFormated(time) { + const timeago = gl.utils.getTimeago(); + + return timeago.format(time); + }, + + tooltipTitle(time) { + return gl.utils.formatDate(time); + }, + }, +}; diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index d2ec1791d2bbd4e7381cda490ca90d1efb0e47f8..b8ba77f45134db00faeddbafe5ce83dd97fe6f14 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -34,6 +34,7 @@ @import "framework/selects.scss"; @import "framework/sidebar.scss"; @import "framework/tables.scss"; +@import "framework/notes.scss"; @import "framework/timeline.scss"; @import "framework/typography.scss"; @import "framework/zen.scss"; diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index d64b1237b2ce6a57348d6af87737941e8df000f6..75907c35b7e900fcc35c6328412c35fa9980ccf5 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -10,7 +10,7 @@ top: 0; margin-top: 3px; padding: $gl-padding; - z-index: 9; + z-index: 300; width: 300px; font-size: 14px; background-color: $white-light; diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 3dec911d2891cc35fbbc1101e5ded4df3e130901..fefe5575d9bec1b77bb639006c8debfd67833875 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -23,7 +23,6 @@ .row-content-block { margin-top: 0; - margin-bottom: -$gl-padding; background-color: $gray-light; padding: $gl-padding; margin-bottom: 0; diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss index d86ae57cd9ac577dc8b1a04db656c7cb6b275857..2d6bc17d4ff430f2cc7b8be631b3ec6b543bf492 100644 --- a/app/assets/stylesheets/framework/emojis.scss +++ b/app/assets/stylesheets/framework/emojis.scss @@ -1,5 +1,4 @@ gl-emoji { - display: inline-block; display: inline-flex; vertical-align: middle; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index f0994e968c822cfe8915986b9b3b4709ec7bc693..d191bbb226cb957edc07caab81174ffff2934b5d 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -104,6 +104,22 @@ padding: 2px 7px; } + .name { + background-color: $filter-name-resting-color; + color: $filter-name-text-color; + border-radius: 2px 0 0 2px; + margin-right: 1px; + text-transform: capitalize; + } + + .value-container { + background-color: $white-normal; + color: $filter-value-text-color; + border-radius: 0 2px 2px 0; + margin-right: 5px; + padding-right: 8px; + } + .value { padding-right: 0; } @@ -111,7 +127,7 @@ .remove-token { display: inline-block; padding-left: 4px; - padding-right: 8px; + padding-right: 0; .fa-close { color: $gl-text-color-secondary; @@ -132,21 +148,6 @@ } } - .name { - background-color: $filter-name-resting-color; - color: $filter-name-text-color; - border-radius: 2px 0 0 2px; - margin-right: 1px; - text-transform: capitalize; - } - - .value-container { - background-color: $white-normal; - color: $filter-value-text-color; - border-radius: 0 2px 2px 0; - margin-right: 5px; - } - .selected { .name { background-color: $filter-name-selected-color; diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index eadb9409fee692d45d7967ceaf6b1175e173d34e..25b4feca3c3a08aaad5cb52a6c2f3f7abc4dbe68 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -36,6 +36,10 @@ border-radius: 0; } } + + &:empty { + margin: 0; + } } @media (max-width: $screen-sm-max) { diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index d76053fe72aecf05661db0fa5fde6d3bef53dd2f..491636535489a563757d2447b13be78f48b188eb 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -11,7 +11,6 @@ > li { padding: 10px 15px; min-height: 20px; - border-bottom: 1px solid $list-border-light; border-bottom: 1px solid $list-border; &::after { diff --git a/app/assets/stylesheets/framework/notes.scss b/app/assets/stylesheets/framework/notes.scss new file mode 100644 index 0000000000000000000000000000000000000000..d349e3fad9ce01dec8144177b386914309ec8ded --- /dev/null +++ b/app/assets/stylesheets/framework/notes.scss @@ -0,0 +1,14 @@ +@mixin notes-media($condition, $breakpoint-width) { + @media (#{$condition}-width: ($breakpoint-width)) { + @content; + } + + // Diff is side by side + .notes_content.parallel & { + // We hide at double what we normally hide at because + // there are two columns of notes + @media (#{$condition}-width: (2 * $breakpoint-width)) { + @content; + } + } +} diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 9ab17e67d4c542c6a8137c6af128c37b2344a7d0..5ae833cd5f6914e6545e6658e9a0cc66d9018e8e 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -96,7 +96,6 @@ .select2-search-field input { padding: 5px $gl-padding / 2; - font-size: 13px; height: auto; font-family: inherit; font-size: inherit; diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss index ddccfc96819a3ba1d90081a0c2a4bfbbda6f12cf..cec3b54d5678ac7b72f6c3ad290629f8a971dc93 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -3,6 +3,12 @@ margin: 0; padding: 0; + &::before { + @include notes-media('max', $screen-xs-max) { + background: none; + } + } + .system-note { .note-text { color: $gl-text-color !important; @@ -23,6 +29,16 @@ .timeline-entry-inner { position: relative; + + @include notes-media('max', $screen-xs-max) { + .timeline-icon { + display: none; + } + + .timeline-content { + margin-left: 0; + } + } } &:target, @@ -40,24 +56,6 @@ } } -@media (max-width: $screen-xs-max) { - .timeline { - &::before { - background: none; - } - } - - .timeline-entry .timeline-entry-inner { - .timeline-icon { - display: none; - } - - .timeline-content { - margin-left: 0; - } - } -} - .discussion .timeline-entry { margin: 0; border-right: none; diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 0c3407f34f884fa3d9498bb9d14ff3043734e797..785b09e622ffe58473a3bd3cc568decb9478e0d5 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -21,6 +21,10 @@ margin-top: 0; } + > :last-child { + margin-bottom: 0; + } + // Single code lines should wrap code { font-family: $monospace_font; @@ -157,7 +161,7 @@ ul, ol { padding: 0; - margin: 0 0 16px !important; + margin: 0 0 16px; } ul:dir(rtl), diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 68d7ab4bf8411d66d4cd383430034b1b9e9fd996..ebe662136d5e458b33e0eb2a4881773e72561617 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -72,7 +72,9 @@ @media (min-width: $screen-sm-min) { height: 475px; // Needed for PhantomJS + // scss-lint:disable DuplicateProperty height: calc(100vh - 222px); + // scss-lint:enable DuplicateProperty min-height: 475px; transition: width .2s; diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 14a62b6cbf005de0db5083e6fc9de29e02f1fe34..e35558ad8e82a0254d7f6f3a0695c1baeaabc02c 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -29,129 +29,140 @@ } } -.build-page { - pre.trace { - background: $builds-trace-bg; - color: $white-light; - font-family: $monospace_font; - white-space: pre-wrap; - overflow: auto; - overflow-y: hidden; - font-size: 12px; - - .fa-spinner { - font-size: 24px; - margin-left: 20px; - } - } - - .environment-information { - background-color: $gray-light; - border: 1px solid $border-color; - padding: 12px $gl-padding; - border-radius: $border-radius-default; +@keyframes blinking-scroll-button { + 0% { opacity: 0.2; } + 25% { opacity: 0.5; } + 50% { opacity: 0.7; } + 100% { opacity: 1; } +} - svg { - position: relative; - top: 1px; - margin-right: 5px; - } +.build-page { + .sticky { + position: absolute; + left: 0; + right: 0; } - .truncated-info { - text-align: center; - border-bottom: 1px solid; - background-color: $black; - height: 45px; - padding: 15px; + .build-trace-container { + position: absolute; + top: 225px; + left: 15px; + bottom: 10px; + background: $black; + color: $gray-darkest; + font-family: $monospace_font; + font-size: 12px; - &.affix { - top: 0; + &.sidebar-expanded { + right: 305px; } - // with sidebar - &.affix.sidebar-expanded { - right: 312px; - left: 22px; + &.sidebar-collapsed { + right: 16px; } - // without sidebar - &.affix.sidebar-collapsed { - right: 20px; - left: 20px; + code { + background: $black; + color: $gray-darkest; } - &.affix-top { - position: absolute; + .top-bar { top: 0; - margin: 0 auto; - right: 5px; - left: 5px; - } + height: 35px; + display: flex; + justify-content: flex-end; + border-bottom: 1px outset $white-light; - .truncated-info-size { - margin: 0 5px; - } + .truncated-info { + margin: 0 auto; + align-self: center; - .raw-link { - color: inherit; - margin-left: 5px; - text-decoration: underline; + .truncated-info-size { + margin: 0 5px; + } + + .raw-link { + color: inherit; + margin-left: 5px; + text-decoration: underline; + } + } } - } -} -.scroll-controls { - height: 100%; + .controllers { + display: flex; + align-self: center; + font-size: 15px; - .scroll-step { - width: 31px; - margin: 0 0 0 auto; - } + svg { + height: 15px; + display: block; + fill: $white-light; + } - .scroll-link, - .autoscroll-container { - right: 25px; - z-index: 1; - } + a, + .btn-scroll { + margin: 0 8px; + color: $white-light; + } - .scroll-link { - position: fixed; - display: block; - margin-bottom: 10px; + .btn-scroll.animate { + .first-triangle { + animation: blinking-scroll-button 1s ease infinite; + animation-delay: .3s; + } - &.scroll-top .gitlab-icon-scroll-up-hover, - &.scroll-top:hover .gitlab-icon-scroll-up, - &.scroll-bottom .gitlab-icon-scroll-down-hover, - &.scroll-bottom:hover .gitlab-icon-scroll-down { - display: none; - } + .second-triangle { + animation: blinking-scroll-button 1s ease infinite; + animation-delay: .2s; + } - &.scroll-top:hover .gitlab-icon-scroll-up-hover, - &.scroll-bottom:hover .gitlab-icon-scroll-down-hover { - display: inline-block; - } + .third-triangle { + animation: blinking-scroll-button 1s ease infinite; + } - &.scroll-top { - top: 10px; - } + &:disabled { + opacity: 1; + } + } - &.scroll-bottom { - bottom: -2px; + .btn-scroll:disabled { + opacity: 0.35; + cursor: not-allowed; + } } } - .autoscroll-container { - position: absolute; + .bash { + top: 35px; + left: 10px; + bottom: 0; + overflow-y: hidden; + padding-bottom: 20px; + padding-right: 20px; } - &.sidebar-expanded { + .environment-information { + background-color: $gray-light; + border: 1px solid $border-color; + padding: 12px $gl-padding; + border-radius: $border-radius-default; - .scroll-link, - .autoscroll-container { - right: ($gutter_width + ($gl-padding * 2)); + svg { + position: relative; + top: 1px; + margin-right: 5px; } } + + .build-loader-animation { + position: relative; + width: 6px; + height: 6px; + margin: auto auto 12px 2px; + border-radius: 50%; + animation: blinking-dots 1s linear infinite; + } } .status-message { @@ -223,32 +234,6 @@ } } -.build-trace { - background: $black; - color: $gray-darkest; - white-space: pre; - overflow-x: auto; - font-size: 12px; - position: relative; - - .fa-spinner { - font-size: 24px; - } - - .bash { - display: block; - } - - .build-loader-animation { - position: relative; - width: 6px; - height: 6px; - margin: auto auto 12px 2px; - border-radius: 50%; - animation: blinking-dots 1s linear infinite; - } -} - .right-sidebar.build-sidebar { padding: $gl-padding 0; @@ -390,6 +375,10 @@ .container-fluid.container-limited { max-width: 100%; } + + .content-wrapper { + padding-bottom: 6px; + } } .build-detail-row { diff --git a/app/assets/stylesheets/pages/ci_projects.scss b/app/assets/stylesheets/pages/ci_projects.scss index 9064383239097e0f2cec119d2817f64c24c9ab72..7b4eb689f1b40f4c7add4bae43c7cca062aedcf6 100644 --- a/app/assets/stylesheets/pages/ci_projects.scss +++ b/app/assets/stylesheets/pages/ci_projects.scss @@ -36,7 +36,6 @@ pre.commit-message { background: none; padding: 0; - margin: 0; border: none; margin: 20px 0; border-radius: 0; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 58715c4c0831d92daad1fbe6a503bb3800924eda..b58922626fa72fc90dd2a88f3507aa2679609e5a 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -94,7 +94,6 @@ .old_line, .new_line { margin: 0; - padding: 0; border: none; padding: 0 5px; border-right: 1px solid; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 48d3b7b1d07e6ada1e1c8b893811d58329d4d248..f269d53093d6ddf2b5fbb6534b3be48e222d5c2c 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -64,6 +64,10 @@ } } + .btn .text-center { + display: inline; + } + .commit-title { margin: 0; } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index d79ae47f589f224433931c0508711c678f98e9cf..c2346f2f1c353886268f0830536b7488154bc94c 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -431,7 +431,7 @@ } .detail-page-description { - padding: 16px 0 0; + padding: 16px 0; small { color: $gray-darkest; @@ -441,7 +441,7 @@ .edited-text { color: $gray-darkest; display: block; - margin: 0 0 16px; + margin: 16px 0 0; .author_link { color: $gray-darkest; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index bee9b13b375892c96e34cdb3420eb54a3ebda04b..702e76625271506b3f59d2783f6299a2c9c2241b 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -204,7 +204,6 @@ ul.related-merge-requests > li { .dropdown-toggle { .fa-caret-down { pointer-events: none; - margin-left: 0; color: inherit; margin-left: 0; } diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 8dbac76e30ad5b1b62122a889871bd43efd494c8..971d54e7472ddc44536368bb792f6d88119f7d8f 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -184,4 +184,4 @@ } } } -} \ No newline at end of file +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 183be86f650fb13a2dc130a7313c5ddf21eb0f77..2dc7f73a295e79e31fa337e6c715e9f2b2c691e6 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -520,17 +520,13 @@ position: absolute; border-top: 2px solid $border-color; height: 1px; - top: 8px; + top: 9px; width: 8px; left: 0; } &:last-child { margin-bottom: 0; - - &::before { - top: 14px; - } } } @@ -539,7 +535,7 @@ width: 2px; background: $border-color; position: absolute; - top: -5px; + top: -9px; } } diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 49e453c7dbcf1392791a6526bbb62a16066c8cbf..875e47cdff3de75a48a797545b62cf0043a0f4e9 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -28,7 +28,7 @@ .note-edit-form { .note-form-actions { position: relative; - margin: $gl-padding 0; + margin: $gl-padding 0 0; } .note-preview-holder { @@ -124,10 +124,18 @@ } .discussion-form { - padding: $gl-padding-top $gl-padding; + padding: $gl-padding-top $gl-padding $gl-padding; background-color: $white-light; } +.discussion-notes .disabled-comment { + padding: 6px 0; +} + +.notes-form > li { + border: 0; +} + .note-edit-form { display: none; font-size: 14px; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index a8a996c10358bac81297bb038e096b2e8798e0d2..cffd3b6060daca875b695de507365003807f1625 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -14,24 +14,11 @@ ul.notes { margin: 0; padding: 0; - .timeline-icon { - float: left; - - svg { - width: 16px; - height: 16px; - fill: $gray-darkest; - position: absolute; - left: 0; - top: 16px; - } - } - .timeline-content { margin-left: 55px; &.timeline-content-form { - @media (max-width: $screen-sm-max) { + @include notes-media('max', $screen-sm-max) { margin-left: 0; } } @@ -56,21 +43,22 @@ ul.notes { position: relative; } - .note { - padding: $gl-padding $gl-btn-padding 0; + > li { + padding: $gl-padding $gl-btn-padding; display: block; position: relative; border-bottom: 1px solid $white-normal; + &:last-child { + // Override `.timeline > li:last-child { border-bottom: none; }` + border-bottom: 1px solid $white-normal; + } + &.being-posted { pointer-events: none; opacity: 0.5; .dummy-avatar { - display: inline-block; - height: 40px; - width: 40px; - border-radius: 50%; background-color: $kdb-border; border: 1px solid darken($kdb-border, 25%); } @@ -126,13 +114,13 @@ ul.notes { .note-awards { .js-awards-block { - margin-bottom: 16px; + margin-top: 16px; } } .note-header { - @media (max-width: $screen-xs-min) { + @include notes-media('max', $screen-xs-min) { .inline { display: block; } @@ -161,10 +149,10 @@ ul.notes { .system-note { font-size: 14px; - padding: 0; + padding-left: 0; clear: both; - @media (min-width: $screen-sm-min) { + @include notes-media('min', $screen-sm-min) { margin-left: 65px; } @@ -198,11 +186,22 @@ ul.notes { } } - .timeline-content { - padding: 14px 10px; + .timeline-icon { + float: left; - @media (min-width: $screen-sm-min) { - margin-left: 20px; + svg { + width: 16px; + height: 16px; + fill: $gray-darkest; + position: absolute; + left: 0; + top: 2px; + } + } + + .timeline-content { + @include notes-media('min', $screen-sm-min) { + margin-left: 30px; } } @@ -371,7 +370,7 @@ ul.notes { display: flex; justify-content: space-between; - @media (max-width: $screen-xs-max) { + @include notes-media('max', $screen-xs-max) { flex-flow: row wrap; } } @@ -386,7 +385,7 @@ ul.notes { } .note-header-author-name { - @media (max-width: $screen-xs-max) { + @include notes-media('max', $screen-xs-max) { display: none; } } @@ -394,7 +393,7 @@ ul.notes { .note-headline-light { display: inline; - @media (max-width: $screen-xs-min) { + @include notes-media('max', $screen-xs-min) { display: block; } } @@ -436,7 +435,7 @@ ul.notes { margin-left: 10px; color: $gray-darkest; - @media (max-width: $screen-xs-max) { + @include notes-media('max', $screen-xs-max) { float: none; margin-left: 0; } @@ -447,7 +446,7 @@ ul.notes { } .discussion-actions { - @media (max-width: $screen-md-max) { + @include notes-media('max', $screen-md-max) { float: none; margin-left: 0; @@ -461,7 +460,7 @@ ul.notes { display: inline; line-height: 20px; - @media (min-width: $screen-sm-min) { + @include notes-media('min', $screen-sm-min) { margin-left: 10px; line-height: 24px; } @@ -596,10 +595,15 @@ ul.notes { .discussion-body, .diff-file { .notes .note { - padding: 10px 15px; + padding-left: $gl-padding; + padding-right: $gl-padding; &.system-note { - padding: 0; + padding-left: 0; + + @media (min-width: $screen-sm-min) { + margin-left: 70px; + } } } } @@ -613,17 +617,11 @@ ul.notes { } .disabled-comment { - margin-left: -$gl-padding-top; - margin-right: -$gl-padding-top; background-color: $gray-light; border-radius: $border-radius-base; border: 1px solid $border-gray-normal; color: $note-disabled-comment-color; - line-height: 200px; - - .disabled-comment-text { - line-height: normal; - } + padding: 90px 0; a { color: $gl-link-color; @@ -631,7 +629,7 @@ ul.notes { } .line-resolve-all-container { - @media (min-width: $screen-sm-min) { + @include notes-media('min', $screen-sm-min) { margin-right: 0; padding-left: $gl-padding; } @@ -673,7 +671,7 @@ ul.notes { .line-resolve-all { vertical-align: middle; display: inline-block; - padding: 6px 10px; + padding: 5px 10px 6px; background-color: $gray-light; border: 1px solid $border-color; border-radius: $border-radius-default; @@ -686,6 +684,10 @@ ul.notes { .line-resolve-btn { margin-right: 5px; + + svg { + vertical-align: middle; + } } } @@ -722,6 +724,10 @@ ul.notes { } } +.line-resolve-text { + vertical-align: middle; +} + .discussion-next-btn { svg { margin: 0; @@ -738,10 +744,6 @@ ul.notes { // Merge request notes in diffs .diff-file { - // Diff is side by side - .notes_content.parallel .note-header .note-header-author-name { - display: block; - } // Diff is inline .notes_content .note-header .note-headline-light { display: inline-block; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 292584eba28b8320546d3fa171f394a59b01ee72..cf2e565dd2d99d2fbda11a1d33b30f8aee808f1e 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -88,6 +88,10 @@ } } + .btn .text-center { + display: inline; + } + .tooltip { white-space: nowrap; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index f0bf3d4c267c286ffc55172dcd0d0a584b195cc9..24ab2bedea24fdb1635b8229665aef85787a09d6 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -247,7 +247,6 @@ font-size: 13px; font-weight: 600; line-height: 13px; - padding: $gl-vert-padding $gl-padding; letter-spacing: .4px; padding: 6px 14px; text-align: center; @@ -384,10 +383,6 @@ a.deploy-project-label { } } -.last-push-widget { - margin-top: -1px; -} - .fork-namespaces { .row { -webkit-flex-wrap: wrap; diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index e2f5aa8508ecc3bede5636a2ab6adebb8aa9e77f..907717dcb960510510d4b79a32a0d9948d979b06 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -9,7 +9,7 @@ class AutocompleteController < ApplicationController @users = @users.where.not(id: params[:skip_users]) if params[:skip_users].present? @users = @users.active @users = @users.reorder(:name) - @users = @users.page(params[:page]) + @users = @users.page(params[:page]).per(params[:per_page]) if params[:todo_filter].present? && current_user @users = @users.todo_authors(current_user.id, params[:todo_state_filter]) diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 5a1efcab1a3f9483f7a33f891adc39a76a7c00af..3d49ea975918f15c14fd4ae2d5e2877d720ae9d4 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -8,7 +8,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController @projects = load_projects(params.merge(non_public: true)).page(params[:page]) respond_to do |format| - format.html { @last_push = current_user.recent_push } + format.html format.atom do load_events render layout: false @@ -25,7 +25,6 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController @projects = load_projects(params.merge(starred: true)). includes(:forked_from_project, :tags).page(params[:page]) - @last_push = current_user.recent_push @groups = [] respond_to do |format| diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 79d420a32d3d81c71e9ffed27a378bcfe294ee4f..6195121b931b16d946385cb52143dd6fd9ca87e8 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -9,8 +9,6 @@ class DashboardController < Dashboard::ApplicationController respond_to :html def activity - @last_push = current_user.recent_push - respond_to do |format| format.html diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 965ced4d37252344f0344a9d06a433a2c2f0763e..18a2d69db293f3735ee2241ddca8c182631fd087 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -64,6 +64,8 @@ class GroupsController < Groups::ApplicationController end def subgroups + return not_found unless Group.supports_nested_groups? + @nested_groups = GroupsFinder.new(current_user, parent: group).execute @nested_groups = @nested_groups.search(params[:filter_groups]) if params[:filter_groups].present? end @@ -165,7 +167,6 @@ class GroupsController < Groups::ApplicationController def user_actions if current_user - @last_push = current_user.recent_push @notification_setting = current_user.notification_settings_for(group) end end diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 206d0753f0827924a60eafbe3b7f2767703b8210..0081bbd92b352771624b9938cd3868af5b936cb6 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -56,7 +56,7 @@ module ButtonHelper content_tag (append_link ? :a : :span), protocol, class: klass, - href: (project.http_url_to_repo(current_user) if append_link), + href: (project.http_url_to_repo if append_link), data: { html: true, placement: placement, diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 98bbcfaaba5f049b4e6b1ebb1ba6f8f9cc5b3e5e..7b0584c42a2a3cfef2c5b808022a2d16b6277c9c 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -85,6 +85,12 @@ module ProjectsHelper @nav_tabs ||= get_project_nav_tabs(@project, current_user) end + def project_search_tabs?(tab) + abilities = Array(search_tab_ability_map[tab]) + + abilities.any? { |ability| can?(current_user, ability, @project) } + end + def project_nav_tab?(name) project_nav_tabs.include? name end @@ -116,6 +122,7 @@ module ProjectsHelper def last_push_event return unless current_user + return current_user.recent_push unless @project project_ids = [@project.id] if fork = current_user.fork_of(@project) @@ -203,7 +210,17 @@ module ProjectsHelper nav_tabs << :container_registry end - tab_ability_map = { + tab_ability_map.each do |tab, ability| + if can?(current_user, ability, project) + nav_tabs << tab + end + end + + nav_tabs.flatten + end + + def tab_ability_map + { environments: :read_environment, milestones: :read_milestone, pipelines: :read_pipeline, @@ -215,14 +232,15 @@ module ProjectsHelper team: :read_project_member, wiki: :read_wiki } + end - tab_ability_map.each do |tab, ability| - if can?(current_user, ability, project) - nav_tabs << tab - end - end - - nav_tabs.flatten + def search_tab_ability_map + @search_tab_ability_map ||= tab_ability_map.merge( + blobs: :download_code, + commits: :download_code, + merge_requests: :read_merge_request, + notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet] + ) end def project_lfs_status(project) @@ -258,7 +276,7 @@ module ProjectsHelper when 'ssh' project.ssh_url_to_repo else - project.http_url_to_repo(current_user) + project.http_url_to_repo end end diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb index a7d1fe4aa479988ff2dc8983165b7284443efc47..1a4f1431bdc2d8d394ffc8cab312eb33bd24ded4 100644 --- a/app/helpers/selects_helper.rb +++ b/app/helpers/selects_helper.rb @@ -45,6 +45,14 @@ module SelectsHelper end end + with_feature_enabled_data_attribute = + case opts.delete(:with_feature_enabled) + when 'issues' then 'data-with-issues-enabled' + when 'merge_requests' then 'data-with-merge-requests-enabled' + end + + opts[with_feature_enabled_data_attribute] = true + hidden_field_tag(id, opts[:selected], opts) end diff --git a/app/helpers/submodule_helper.rb b/app/helpers/submodule_helper.rb index 09b73eee8cf901cde3430da78ba038ef54d45bf0..c0763a8a9c40d4ef1a56ed794fc96dac4a60ac06 100644 --- a/app/helpers/submodule_helper.rb +++ b/app/helpers/submodule_helper.rb @@ -13,6 +13,7 @@ module SubmoduleHelper if url =~ /([^\/:]+)\/([^\/]+(?:\.git)?)\Z/ namespace, project = $1, $2 + project.rstrip! project.sub!(/\.git\z/, '') if self_url?(url, namespace, project) diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index cf6e53c4ca4c6ff28d31c2cabad892da51c25f51..07213ca608a956b70ee44e9ede192975f93de559 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -10,9 +10,9 @@ module Ci has_one :last_pipeline, -> { order(id: :desc) }, class_name: 'Ci::Pipeline' has_many :pipelines - validates :cron, unless: :importing_or_inactive?, cron: true, presence: { unless: :importing_or_inactive? } - validates :cron_timezone, cron_timezone: true, presence: { unless: :importing_or_inactive? } - validates :ref, presence: { unless: :importing_or_inactive? } + validates :cron, unless: :importing?, cron: true, presence: { unless: :importing? } + validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? } + validates :ref, presence: { unless: :importing? } validates :description, presence: true before_save :set_next_run_at @@ -32,10 +32,6 @@ module Ci update_attribute(:active, false) end - def importing_or_inactive? - importing? || inactive? - end - def runnable_by_owner? Ability.allowed?(owner, :create_pipeline, project) end diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index c4463abdfe6bf79c62ae8fb3d8804119df85363b..63d02b76f6b67b5768f3169ba5e15c1c476506ed 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -84,89 +84,6 @@ module Routable joins(:route).where(wheres.join(' OR ')) end end - - # Builds a relation to find multiple objects that are nested under user membership - # - # Usage: - # - # Klass.member_descendants(1) - # - # Returns an ActiveRecord::Relation. - def member_descendants(user_id) - joins(:route). - joins("INNER JOIN routes r2 ON routes.path LIKE CONCAT(r2.path, '/%') - INNER JOIN members ON members.source_id = r2.source_id - AND members.source_type = r2.source_type"). - where('members.user_id = ?', user_id) - end - - # Builds a relation to find multiple objects that are nested under user - # membership. Includes the parent, as opposed to `#member_descendants` - # which only includes the descendants. - # - # Usage: - # - # Klass.member_self_and_descendants(1) - # - # Returns an ActiveRecord::Relation. - def member_self_and_descendants(user_id) - joins(:route). - joins("INNER JOIN routes r2 ON routes.path LIKE CONCAT(r2.path, '/%') - OR routes.path = r2.path - INNER JOIN members ON members.source_id = r2.source_id - AND members.source_type = r2.source_type"). - where('members.user_id = ?', user_id) - end - - # Returns all objects in a hierarchy, where any node in the hierarchy is - # under the user membership. - # - # Usage: - # - # Klass.member_hierarchy(1) - # - # Examples: - # - # Given the following group tree... - # - # _______group_1_______ - # | | - # | | - # nested_group_1 nested_group_2 - # | | - # | | - # nested_group_1_1 nested_group_2_1 - # - # - # ... the following results are returned: - # - # * the user is a member of group 1 - # => 'group_1', - # 'nested_group_1', nested_group_1_1', - # 'nested_group_2', 'nested_group_2_1' - # - # * the user is a member of nested_group_2 - # => 'group1', - # 'nested_group_2', 'nested_group_2_1' - # - # * the user is a member of nested_group_2_1 - # => 'group1', - # 'nested_group_2', 'nested_group_2_1' - # - # Returns an ActiveRecord::Relation. - def member_hierarchy(user_id) - paths = member_self_and_descendants(user_id).pluck('routes.path') - - return none if paths.empty? - - wheres = paths.map do |path| - "#{connection.quote(path)} = routes.path - OR - #{connection.quote(path)} LIKE CONCAT(routes.path, '/%')" - end - - joins(:route).where(wheres.join(' OR ')) - end end def full_name diff --git a/app/models/concerns/select_for_project_authorization.rb b/app/models/concerns/select_for_project_authorization.rb index 50a1d7fc3e1f85d7619ce85bdf88296905b67688..58194b0ea1371e09a7e0cbb23468f748bd3e7650 100644 --- a/app/models/concerns/select_for_project_authorization.rb +++ b/app/models/concerns/select_for_project_authorization.rb @@ -3,7 +3,11 @@ module SelectForProjectAuthorization module ClassMethods def select_for_project_authorization - select("members.user_id, projects.id AS project_id, members.access_level") + select("projects.id AS project_id, members.access_level") + end + + def select_as_master_for_project_authorization + select(["projects.id AS project_id", "#{Gitlab::Access::MASTER} AS access_level"]) end end end diff --git a/app/models/group.rb b/app/models/group.rb index 6aab477f431054aadeb0ff5e38208f0d63653995..be944da5a67db08bd054f02776d04efc7743e60a 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -38,6 +38,10 @@ class Group < Namespace after_save :update_two_factor_requirement class << self + def supports_nested_groups? + Gitlab::Database.postgresql? + end + # Searches for groups matching the given query. # # This method uses ILIKE on PostgreSQL and LIKE on MySQL. @@ -78,7 +82,7 @@ class Group < Namespace if current_scope.joins_values.include?(:shared_projects) joins('INNER JOIN namespaces project_namespace ON project_namespace.id = projects.namespace_id') .where('project_namespace.share_with_group_lock = ?', false) - .select("members.user_id, projects.id AS project_id, LEAST(project_group_links.group_access, members.access_level) AS access_level") + .select("projects.id AS project_id, LEAST(project_group_links.group_access, members.access_level) AS access_level") else super end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index c06bfe0ccdd773823f8093ec1469373740767d69..b04bed4c0147679a04f948771886c3a95047a7f3 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -107,7 +107,7 @@ class Milestone < ActiveRecord::Base end def participants - User.joins(assigned_issues: :milestone).where("milestones.id = ?", id) + User.joins(assigned_issues: :milestone).where("milestones.id = ?", id).uniq end def self.sort(method) diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 4d59267f71d7336e6175c7bc9480bd73ecb70fe3..aebee06d560b1bb54d65c40344d68f676f264a80 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -176,26 +176,20 @@ class Namespace < ActiveRecord::Base projects.with_shared_runners.any? end - # Scopes the model on ancestors of the record + # Returns all the ancestors of the current namespaces. def ancestors - if parent_id - path = route ? route.path : full_path - paths = [] + return self.class.none unless parent_id - until path.blank? - path = path.rpartition('/').first - paths << path - end - - self.class.joins(:route).where('routes.path IN (?)', paths).reorder('routes.path ASC') - else - self.class.none - end + Gitlab::GroupHierarchy. + new(self.class.where(id: parent_id)). + base_and_ancestors end - # Scopes the model on direct and indirect children of the record + # Returns all the descendants of the current namespace. def descendants - self.class.joins(:route).merge(Route.inside_path(route.path)).reorder('routes.path ASC') + Gitlab::GroupHierarchy. + new(self.class.where(parent_id: id)). + base_and_descendants end def user_ids_for_project_authorizations diff --git a/app/models/project.rb b/app/models/project.rb index 29af57d76643238d7b1410c8fd6a5f3947386942..a59095cb25cc4d232e008d624c116f6e2bbed82a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -271,6 +271,7 @@ class Project < ActiveRecord::Base scope :with_builds_enabled, -> { with_feature_enabled(:builds) } scope :with_issues_enabled, -> { with_feature_enabled(:issues) } + scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) } enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 } @@ -873,10 +874,8 @@ class Project < ActiveRecord::Base url_to_repo end - def http_url_to_repo(user = nil) - credentials = Gitlab::UrlSanitizer.http_credentials_for_user(user) - - Gitlab::UrlSanitizer.new("#{web_url}.git", credentials: credentials).full_url + def http_url_to_repo + "#{web_url}.git" end def user_can_push_to_empty_repo?(user) diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index 4c7f4f5a4291856dc8002c0068376fad4b07d865..def0967525378578a30d2013ced9c5a0a8514761 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -6,6 +6,12 @@ class ProjectAuthorization < ActiveRecord::Base validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true + def self.select_from_union(union) + select(['project_id', 'MAX(access_level) AS access_level']). + from("(#{union.to_sql}) #{ProjectAuthorization.table_name}"). + group(:project_id) + end + def self.insert_authorizations(rows, per_batch = 1000) rows.each_slice(per_batch) do |slice| tuples = slice.map do |tuple| diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index a91a986e195e89e41fcc253f3bd5889280b93b36..25d098b63c08c53ba57e223ac2ca20bff975cc35 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -2,9 +2,10 @@ class JiraService < IssueTrackerService include Gitlab::Routing.url_helpers validates :url, url: true, presence: true, if: :activated? + validates :api_url, url: true, allow_blank: true validates :project_key, presence: true, if: :activated? - prop_accessor :username, :password, :url, :project_key, + prop_accessor :username, :password, :url, :api_url, :project_key, :jira_issue_transition_id, :title, :description before_update :reset_password @@ -25,20 +26,18 @@ class JiraService < IssueTrackerService super do self.properties = { title: issues_tracker['title'], - url: issues_tracker['url'] + url: issues_tracker['url'], + api_url: issues_tracker['api_url'] } end end def reset_password - # don't reset the password if a new one is provided - if url_changed? && !password_touched? - self.password = nil - end + self.password = nil if reset_password? end def options - url = URI.parse(self.url) + url = URI.parse(client_url) { username: self.username, @@ -87,7 +86,8 @@ class JiraService < IssueTrackerService def fields [ - { type: 'text', name: 'url', title: 'URL', placeholder: 'https://jira.example.com' }, + { type: 'text', name: 'url', title: 'Web URL', placeholder: 'https://jira.example.com' }, + { type: 'text', name: 'api_url', title: 'JIRA API URL', placeholder: 'If different from Web URL' }, { type: 'text', name: 'project_key', placeholder: 'Project Key' }, { type: 'text', name: 'username', placeholder: '' }, { type: 'password', name: 'password', placeholder: '' }, @@ -186,7 +186,7 @@ class JiraService < IssueTrackerService end def test_settings - return unless url.present? + return unless client_url.present? # Test settings by getting the project jira_request { jira_project.present? } end @@ -236,20 +236,29 @@ class JiraService < IssueTrackerService end def send_message(issue, message, remote_link_props) - return unless url.present? + return unless client_url.present? jira_request do - if issue.comments.build.save!(body: message) - remote_link = issue.remotelink.build + remote_link = find_remote_link(issue, remote_link_props[:object][:url]) + if remote_link remote_link.save!(remote_link_props) - result_message = "#{self.class.name} SUCCESS: Successfully posted to #{url}." + elsif issue.comments.build.save!(body: message) + new_remote_link = issue.remotelink.build + new_remote_link.save!(remote_link_props) end + result_message = "#{self.class.name} SUCCESS: Successfully posted to #{client_url}." Rails.logger.info(result_message) result_message end end + def find_remote_link(issue, url) + links = jira_request { issue.remotelink.all } + + links.find { |link| link.object["url"] == url } + end + def build_remote_link_props(url:, title:, resolved: false) status = { resolved: resolved @@ -295,7 +304,20 @@ class JiraService < IssueTrackerService yield rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => e - Rails.logger.info "#{self.class.name} Send message ERROR: #{url} - #{e.message}" + Rails.logger.info "#{self.class.name} Send message ERROR: #{client_url} - #{e.message}" nil end + + def client_url + api_url.present? ? api_url : url + end + + def reset_password? + # don't reset the password if a new one is provided + return false if password_touched? + return true if api_url_changed? + return false if api_url.present? + + url_changed? + end end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 189c106b70b6764c4477d62da5537a2e6211aecf..f38fbda78393449c1ba6cb3cc627421699ba19c3 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -42,11 +42,8 @@ class ProjectWiki url_to_repo end - def http_url_to_repo(user = nil) - url = "#{Gitlab.config.gitlab.url}/#{path_with_namespace}.git" - credentials = Gitlab::UrlSanitizer.http_credentials_for_user(user) - - Gitlab::UrlSanitizer.new(url, credentials: credentials).full_url + def http_url_to_repo + "#{Gitlab.config.gitlab.url}/#{path_with_namespace}.git" end def wiki_base_path diff --git a/app/models/user.rb b/app/models/user.rb index 9b0c1ebd7c5dfdddf67d4675f1d5f89b3079088f..3f816a250c292c5bfcf8bf647db0fd1ae463388b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -10,9 +10,12 @@ class User < ActiveRecord::Base include Sortable include CaseSensitivity include TokenAuthenticatable + include IgnorableColumn DEFAULT_NOTIFICATION_LEVEL = :participating + ignore_column :authorized_projects_populated + add_authentication_token_field :authentication_token add_authentication_token_field :incoming_email_token add_authentication_token_field :rss_token @@ -218,7 +221,6 @@ class User < ActiveRecord::Base scope :blocked, -> { with_states(:blocked, :ldap_blocked) } scope :external, -> { where(external: true) } scope :active, -> { with_state(:active).non_internal } - scope :not_in_project, ->(project) { project.users.present? ? where("id not in (:ids)", ids: project.users.map(&:id) ) : all } scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members WHERE user_id IS NOT NULL AND requested_at IS NULL)') } scope :todo_authors, ->(user_id, state) { where(id: Todo.where(user_id: user_id, state: state).select(:author_id)) } scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('last_sign_in_at', 'DESC')) } @@ -510,23 +512,16 @@ class User < ActiveRecord::Base Group.where("namespaces.id IN (#{union.to_sql})") end - def nested_groups - Group.member_descendants(id) - end - + # Returns a relation of groups the user has access to, including their parent + # and child groups (recursively). def all_expanded_groups - Group.member_hierarchy(id) + Gitlab::GroupHierarchy.new(groups).all_groups end def expanded_groups_requiring_two_factor_authentication all_expanded_groups.where(require_two_factor_authentication: true) end - def nested_groups_projects - Project.joins(:namespace).where('namespaces.parent_id IS NOT NULL'). - member_descendants(id) - end - def refresh_authorized_projects Users::RefreshAuthorizedProjectsService.new(self).execute end @@ -535,18 +530,15 @@ class User < ActiveRecord::Base project_authorizations.where(project_id: project_ids).delete_all end - def set_authorized_projects_column - unless authorized_projects_populated - update_column(:authorized_projects_populated, true) - end - end - def authorized_projects(min_access_level = nil) - refresh_authorized_projects unless authorized_projects_populated - - # We're overriding an association, so explicitly call super with no arguments or it would be passed as `force_reload` to the association + # We're overriding an association, so explicitly call super with no + # arguments or it would be passed as `force_reload` to the association projects = super() - projects = projects.where('project_authorizations.access_level >= ?', min_access_level) if min_access_level + + if min_access_level + projects = projects. + where('project_authorizations.access_level >= ?', min_access_level) + end projects end @@ -919,13 +911,13 @@ class User < ActiveRecord::Base end def assigned_open_merge_requests_count(force: false) - Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force) do + Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: 20.minutes) do MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened').execute.count end end def assigned_open_issues_count(force: false) - Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force) do + Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: 20.minutes) do IssuesFinder.new(self, assignee_id: self.id, state: 'opened').execute.count end end diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb index b3247ae36ddeb828fa4af343f07ec3a30e267479..f7eb75395b5f0cc690827ce1574559a59814adea 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -39,6 +39,7 @@ class MergeRequestEntity < IssuableEntity expose :commits_count expose :cannot_be_merged?, as: :has_conflicts expose :can_be_merged?, as: :can_be_merged + expose :remove_source_branch?, as: :remove_source_branch expose :project_archived do |merge_request| merge_request.project.archived? diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 1f6c1f4a7f63c60d05b4a301f189c60d4363ba42..a98b7167765241a65fa60d9c1f5fc0b9a44dc432 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -61,6 +61,16 @@ module Ci private + def update_merge_requests_head_pipeline + merge_requests = MergeRequest.where(source_branch: @pipeline.ref, source_project: @pipeline.project) + + merge_requests = merge_requests.select do |mr| + mr.diff_head_sha == @pipeline.sha + end + + MergeRequest.where(id: merge_requests).update_all(head_pipeline_id: @pipeline.id) + end + def skip_ci? return false unless pipeline.git_commit_message pipeline.git_commit_message =~ /\[(ci[ _-]skip|skip[ _-]ci)\]/i @@ -118,11 +128,6 @@ module Ci origin_sha && origin_sha != Gitlab::Git::BLANK_SHA end - def update_merge_requests_head_pipeline - MergeRequest.where(source_branch: @pipeline.ref, source_project: @pipeline.project). - update_all(head_pipeline_id: @pipeline.id) - end - def error(message, save: false) pipeline.errors.add(:base, message) pipeline.drop if save diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index f1030912c68750e348df38be54803eaa73735963..85c616ca576d56cf63961e5c37efe66f9d94c0bb 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -28,6 +28,7 @@ module Issues notification_service.close_issue(issue, current_user) if notifications todo_service.close_issue(issue, current_user) execute_hooks(issue, 'close') + invalidate_cache_counts(issue.assignees, issue) end issue diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb index 40fbe354492842ecc67d26710c3b717f0e7c946c..80ea6312768bc9c662806938a0d14d702fc19004 100644 --- a/app/services/issues/reopen_service.rb +++ b/app/services/issues/reopen_service.rb @@ -8,6 +8,7 @@ module Issues create_note(issue) notification_service.reopen_issue(issue, current_user) execute_hooks(issue, 'reopen') + invalidate_cache_counts(issue.assignees, issue) end issue diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb index f2053bda83aadbb4dbcb8766999f404d02b6c7bc..2ffc989ed71c984d40445c408caf374c8b2ca465 100644 --- a/app/services/merge_requests/close_service.rb +++ b/app/services/merge_requests/close_service.rb @@ -13,6 +13,7 @@ module MergeRequests notification_service.close_mr(merge_request, current_user) todo_service.close_merge_request(merge_request, current_user) execute_hooks(merge_request, 'close') + invalidate_cache_counts(merge_request.assignees, merge_request) end merge_request diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index b0ae2dfe4ce532a9d6f76bbdd54dc926e5f6098e..fbf171f705e741b70aaacc9412daa6c23792abe4 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -11,7 +11,9 @@ module MergeRequests merge_request = MergeRequest.new merge_request.source_project = source_project + merge_request.source_branch = params[:source_branch] merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch) + merge_request.head_pipeline = head_pipeline_for(merge_request) create(merge_request) end @@ -22,5 +24,21 @@ module MergeRequests todo_service.new_merge_request(issuable, current_user) issuable.cache_merge_request_closes_issues!(current_user) end + + private + + def head_pipeline_for(merge_request) + return unless merge_request.source_project + + sha = merge_request.source_branch_head&.id + + return unless sha + + pipelines = + Ci::Pipeline.where(ref: merge_request.source_branch, project_id: merge_request.source_project.id, sha: sha). + order(id: :desc) + + pipelines.first + end end end diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index e8fb1b597527cb1088988cbefff626bec2cdff94..f0d998731d75be214ac3eb1dc098d35857e5fc04 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -13,6 +13,7 @@ module MergeRequests create_note(merge_request) notification_service.merge_mr(merge_request, current_user) execute_hooks(merge_request, 'merge') + invalidate_cache_counts(merge_request.assignees, merge_request) end private diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb index 54b19e6d65159d36bf11018ea915bda414084950..f2fddf7f3453e0693fe5ae27754650081cdc1f88 100644 --- a/app/services/merge_requests/reopen_service.rb +++ b/app/services/merge_requests/reopen_service.rb @@ -10,6 +10,7 @@ module MergeRequests execute_hooks(merge_request, 'reopen') merge_request.reload_diff(current_user) merge_request.mark_as_unchecked + invalidate_cache_counts(merge_request.assignees, merge_request) end merge_request diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 22736c717254967095d5053524e626280afe7443..1d4d03a8b7db22205323b2bb9b49a8e0a724277d 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -12,7 +12,7 @@ class SearchService @project = if params[:project_id].present? the_project = Project.find_by(id: params[:project_id]) - can?(current_user, :download_code, the_project) ? the_project : nil + can?(current_user, :read_project, the_project) ? the_project : nil else nil end diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index 8f6f5b937c4b42d1f3521fe93ce5314365d8b5f7..3e07b8110277d0c0118da081ca21bf1ba0ec4661 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -73,12 +73,11 @@ module Users # remove - The IDs of the authorization rows to remove. # add - Rows to insert in the form `[user id, project id, access level]` def update_authorizations(remove = [], add = []) - return if remove.empty? && add.empty? && user.authorized_projects_populated + return if remove.empty? && add.empty? User.transaction do user.remove_project_authorizations(remove) unless remove.empty? ProjectAuthorization.insert_authorizations(add) unless add.empty? - user.set_authorized_projects_column end # Since we batch insert authorization rows, Rails' associations may get @@ -101,38 +100,13 @@ module Users end def fresh_authorizations - ProjectAuthorization. - unscoped. - select('project_id, MAX(access_level) AS access_level'). - from("(#{project_authorizations_union.to_sql}) #{ProjectAuthorization.table_name}"). - group(:project_id) - end - - private - - # Returns a union query of projects that the user is authorized to access - def project_authorizations_union - relations = [ - # Personal projects - user.personal_projects.select("#{user.id} AS user_id, projects.id AS project_id, #{Gitlab::Access::MASTER} AS access_level"), - - # Projects the user is a member of - user.projects.select_for_project_authorization, - - # Projects of groups the user is a member of - user.groups_projects.select_for_project_authorization, - - # Projects of subgroups of groups the user is a member of - user.nested_groups_projects.select_for_project_authorization, - - # Projects shared with groups the user is a member of - user.groups.joins(:shared_projects).select_for_project_authorization, - - # Projects shared with subgroups of groups the user is a member of - user.nested_groups.joins(:shared_projects).select_for_project_authorization - ] + klass = if Group.supports_nested_groups? + Gitlab::ProjectAuthorizations::WithNestedGroups + else + Gitlab::ProjectAuthorizations::WithoutNestedGroups + end - Gitlab::SQL::Union.new(relations) + klass.new(user).calculate end end end diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb index 6819886ebf42acd8bfcf29fb0926e4c9247b1c9d..a9b76c7c960321dd246b2d57cbf5e33d3a8cf733 100644 --- a/app/validators/dynamic_path_validator.rb +++ b/app/validators/dynamic_path_validator.rb @@ -6,16 +6,21 @@ # Values are checked for formatting and exclusion from a list of illegal path # names. class DynamicPathValidator < ActiveModel::EachValidator + extend Gitlab::Git::EncodingHelper + class << self def valid_user_path?(path) + encode!(path) "#{path}/" =~ Gitlab::PathRegex.root_namespace_path_regex end def valid_group_path?(path) + encode!(path) "#{path}/" =~ Gitlab::PathRegex.full_namespace_path_regex end def valid_project_path?(path) + encode!(path) "#{path}/" =~ Gitlab::PathRegex.full_project_path_regex end end diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml index 2e5f120c4e4ca500ee2f54070bef95b799e64886..9b9559c7fe5bece518a02c1bb640cb9a93d1f35f 100644 --- a/app/views/admin/system_info/show.html.haml +++ b/app/views/admin/system_info/show.html.haml @@ -31,3 +31,8 @@ %h1 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])} %p= disk[:disk_name] %p= disk[:mount_path] + .col-sm-4 + .light-well + %h4 Uptime + .data + %h1= time_ago_with_tooltip(Rails.application.config.booted_at) diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml index e1b270a08c25b4b8ea1ac270ce9df6e1fb30ebc2..a676eba2aee848517df130dd99558a71b5e360a0 100644 --- a/app/views/dashboard/_activities.html.haml +++ b/app/views/dashboard/_activities.html.haml @@ -1,6 +1,3 @@ -.hidden-xs - = render "events/event_last_push", event: @last_push - .nav-block.activities .controls = link_to dashboard_projects_path(rss_url_options), class: 'btn rss-btn has-tooltip', title: 'Subscribe' do diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml index 190ad4b40a5c698d329b2a357995ca6694894e51..f893c3e1675604e3d15ce1d83fb76d8e6e697c1a 100644 --- a/app/views/dashboard/activity.html.haml +++ b/app/views/dashboard/activity.html.haml @@ -1,10 +1,16 @@ +- @no_container = true + = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") - page_title "Activity" - header_title "Activity", activity_dashboard_path -= render 'dashboard/activity_head' +.hidden-xs + = render "projects/last_push" + +%div{ class: container_class } + = render 'dashboard/activity_head' -%section.activities - = render 'activities' + %section.activities + = render 'activities' diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml index faa68468043611cc4e9dbf25c38eaab212f9e59f..d6b46dee0e44229b77e4cf2c14ad5b517ebf4dff 100644 --- a/app/views/dashboard/issues.html.haml +++ b/app/views/dashboard/issues.html.haml @@ -8,7 +8,7 @@ .nav-controls = link_to params.merge(rss_url_options), class: 'btn has-tooltip', title: 'Subscribe' do = icon('rss') - = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue" + = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues' = render 'shared/issuable/filter', type: :issues = render 'shared/issues' diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml index 12966c01950818a382f67ef21c59c0e35ff317fe..6f6afe161d1ad6192ef571642d0f0e707d132d9f 100644 --- a/app/views/dashboard/merge_requests.html.haml +++ b/app/views/dashboard/merge_requests.html.haml @@ -4,7 +4,7 @@ .top-area = render 'shared/issuable/nav', type: :merge_requests .nav-controls - = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request" + = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", with_feature_enabled: 'merge_requests' = render 'shared/issuable/filter', type: :merge_requests = render 'shared/merge_requests' diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index 596499230f9c75308a48fae42f3b9f4899847bfe..3b2a555a1439bc6250c20015383137159fcab9f6 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -1,19 +1,21 @@ +- @no_container = true + = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") - page_title "Projects" - header_title "Projects", dashboard_projects_path -- unless show_user_callout? - = render 'shared/user_callout' += render "projects/last_push" -- if @projects.any? || params[:name] - = render 'dashboard/projects_head' +%div{ class: container_class } + - unless show_user_callout? + = render 'shared/user_callout' -- if @last_push - = render "events/event_last_push", event: @last_push + - if @projects.any? || params[:name] + = render 'dashboard/projects_head' -- if @projects.any? || params[:name] - = render 'projects' -- else - = render "zero_authorized_projects" + - if @projects.any? || params[:name] + = render 'projects' + - else + = render "zero_authorized_projects" diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml index 162ae153b1c53b5856e911e0f979038129bf33f7..99efe9c9b866f701d2e8f18a8060e0c309685dbc 100644 --- a/app/views/dashboard/projects/starred.html.haml +++ b/app/views/dashboard/projects/starred.html.haml @@ -1,13 +1,15 @@ +- @no_container = true + - page_title "Starred Projects" - header_title "Projects", dashboard_projects_path -= render 'dashboard/projects_head' += render "projects/last_push" -- if @last_push - = render "events/event_last_push", event: @last_push +%div{ class: container_class } + = render 'dashboard/projects_head' -- if @projects.any? || params[:filter_projects] - = render 'projects' -- else - %h3 You don't have starred projects yet - %p.slead Visit project page and press on star icon and it will appear on this page. + - if @projects.any? || params[:filter_projects] + = render 'projects' + - else + %h3 You don't have starred projects yet + %p.slead Visit project page and press on star icon and it will appear on this page. diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index 7ba3f3f6c42a5d44b213ed5bf60d92f9d217d671..db5ab9399482d2f7398fe003fef9b5832188554c 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -1,10 +1,11 @@ .discussion-notes %ul.notes{ data: { discussion_id: discussion.id } } = render partial: "shared/notes/note", collection: discussion.notes, as: :note - .flash-container - - if current_user - .discussion-reply-holder + .flash-container + + .discussion-reply-holder + - if can_create_note? - if discussion.potentially_resolvable? - line_type = local_assigns.fetch(:line_type, nil) @@ -19,3 +20,10 @@ = render "discussions/jump_to_next", discussion: discussion - else = link_to_reply_discussion(discussion) + - elsif !current_user + .disabled-comment.text-center + Please + = link_to "register", new_session_path(:user, redirect_to_referer: 'yes') + or + = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes') + to reply diff --git a/app/views/events/_event_last_push.html.haml b/app/views/events/_event_last_push.html.haml deleted file mode 100644 index 1584695a62b68138fa4ef72d888592eec31eb959..0000000000000000000000000000000000000000 --- a/app/views/events/_event_last_push.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -- if show_last_push_widget?(event) - .row-content-block.clear-block.last-push-widget - .event-last-push - .event-last-push-text - %span You pushed to - = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: h(event.project.name) do - %strong= event.ref_name - %span at - %strong= link_to_project event.project - #{time_ago_with_tooltip(event.created_at)} - - .pull-right - = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do - Create merge request diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index c0943100ae3fc89450d91469a93e0080c957fb93..769ac655d0a54f36f419f2694d78bdf4425de0cf 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -7,7 +7,7 @@ %span.pushed #{event.action_name} #{event.ref_type} %strong - commits_link = namespace_project_commits_path(project.namespace, project, event.ref_name) - = link_to_if project.repository.branch_exists?(event.ref_name), event.ref_name, commits_link + = link_to_if project.repository.branch_exists?(event.ref_name), event.ref_name, commits_link, class: 'ref-name' = render "events/event_scope", event: event diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml index d7851c7999033fb263b524879c427743ed7b39df..fd6e7111f38b5c4f1681caa9cad28c336f468879 100644 --- a/app/views/groups/_activities.html.haml +++ b/app/views/groups/_activities.html.haml @@ -1,6 +1,3 @@ -.hidden-xs - = render "events/event_last_push", event: @last_push - .nav-block .controls = link_to group_path(@group, rss_url_options), class: 'btn rss-btn has-tooltip' , title: 'Subscribe' do diff --git a/app/views/groups/_head.html.haml b/app/views/groups/_head.html.haml index 873504099d4c0d5b51c23160d0464351d3dcb5c7..0f63774fb9be9c0244426f9c09795f84b73a2ca9 100644 --- a/app/views/groups/_head.html.haml +++ b/app/views/groups/_head.html.haml @@ -12,3 +12,6 @@ = link_to activity_group_path(@group), title: 'Activity' do %span Activity + +.hidden-xs + = render "projects/last_push" diff --git a/app/views/groups/_show_nav.html.haml b/app/views/groups/_show_nav.html.haml index b2097e8874102129e2cac59e76270d2818903413..35b75bc09237c9fcd60b45410d364ae8d5ede93e 100644 --- a/app/views/groups/_show_nav.html.haml +++ b/app/views/groups/_show_nav.html.haml @@ -2,6 +2,7 @@ = nav_link(page: group_path(@group)) do = link_to group_path(@group) do Projects - = nav_link(page: subgroups_group_path(@group)) do - = link_to subgroups_group_path(@group) do - Subgroups + - if Group.supports_nested_groups? + = nav_link(page: subgroups_group_path(@group)) do + = link_to subgroups_group_path(@group) do + Subgroups diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 18997baa998908e5cf484b66e978706102f16d1c..80a8ba4a755e717917f34fa4143802e05a37984d 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -6,7 +6,6 @@ = render 'groups/head' = render 'groups/home_panel' - .groups-header{ class: container_class } .top-area = render 'groups/show_nav' diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index f61324649101edfd73a4b2d1920e0952e6bbb0e2..86779eeaf15161d957b7e5501caff89e8441d6d4 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -5,7 +5,7 @@ .fade-right = icon('angle-right') %ul.nav-links.scrolling-tabs - = nav_link(controller: %w(dashboard admin projects users groups builds runners), html_options: {class: 'home'}) do + = nav_link(controller: %w(dashboard admin projects users groups builds runners cohorts), html_options: {class: 'home'}) do = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do %span Overview diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index f5bb7364d4ada84dbf1f7f502a4af9a90586fe72..10f581d751b690aa6ac123b420977de4fae4c198 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -1,5 +1,3 @@ -- @no_container = true - %div{ class: container_class } .nav-block.activity-filter-block.activities .controls diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml index f8a6e98d2804a1cd70460b0efd77b157ad9a4908..e8b1940af2dcbcfa24bfa6cf3f45ac354f0b4454 100644 --- a/app/views/projects/_last_push.html.haml +++ b/app/views/projects/_last_push.html.haml @@ -1,18 +1,18 @@ -- if event = last_push_event - - if show_last_push_widget?(event) - .row-content-block.top-block.hidden-xs.white - %div{ class: container_class } - .event-last-push - .event-last-push-text - %span You pushed to - = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name, class: 'commit-sha') do - %strong= event.ref_name - - if @project && event.project != @project - %span at - %strong= link_to_project event.project - = clipboard_button(text: event.ref_name, class: 'btn-clipboard btn-transparent', title: 'Copy branch to clipboard') - #{time_ago_with_tooltip(event.created_at)} +- event = last_push_event +- if event && show_last_push_widget?(event) + .row-content-block.top-block.hidden-xs.white + .event-last-push + .event-last-push-text + %span You pushed to + %strong + = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), class: 'ref-name' - .pull-right - = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do - Create merge request + - if event.project != @project + %span at + %strong= link_to_project event.project + + #{time_ago_with_tooltip(event.created_at)} + + .pull-right + = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do + Create merge request diff --git a/app/views/projects/activity.html.haml b/app/views/projects/activity.html.haml index 27c8e3c7fca68ddf4b48ba1eb0fb743324c8e747..ef8d8051cbf40a679fcbb1e94a05bdde3d0bbcd7 100644 --- a/app/views/projects/activity.html.haml +++ b/app/views/projects/activity.html.haml @@ -1,3 +1,5 @@ +- @no_container = true + - page_title "Activity" = render "projects/head" diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index 67f57b5e4b905d2e047fd09439957b05414cd32b..41f75a491a54e3628c3d7438d0d3de6c0da7c87a 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -1,13 +1,14 @@ - @no_container = true + - page_title @blob.path, @ref = render "projects/commits/head" - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('blob') -%div{ class: container_class } - = render 'projects/last_push' += render 'projects/last_push' +%div{ class: container_class } #tree-holder.tree-holder = render 'blob', blob: @blob diff --git a/app/views/projects/boards/components/sidebar/_assignee.html.haml b/app/views/projects/boards/components/sidebar/_assignee.html.haml index 48f8c6560800957d73c3d5ad829236d462287562..e8db868f49b2ef66e8a12a85c9c5898dffb3e972 100644 --- a/app/views/projects/boards/components/sidebar/_assignee.html.haml +++ b/app/views/projects/boards/components/sidebar/_assignee.html.haml @@ -14,7 +14,10 @@ name: "issue[assignee_ids][]", ":value" => "assignee.id", "v-if" => "issue.assignees", - "v-for" => "assignee in issue.assignees" } + "v-for" => "assignee in issue.assignees", + ":data-avatar_url" => "assignee.avatar", + ":data-name" => "assignee.name", + ":data-username" => "assignee.username" } .dropdown %button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: "button", ref: "assigneeDropdown", data: { toggle: "dropdown", field_name: "issue[assignee_ids][]", first_user: (current_user.username if current_user), current_user: "true", project_id: @project.id, null_user: "true", multi_select: "true", 'max-select' => 1, dropdown: { header: 'Assignee' } }, ":data-issuable-id" => "issue.id", diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml index 8cd4144952b457506490e3cfc587cc81070d601b..3e83142377b2d3b52ba589ee0e3482a386719a34 100644 --- a/app/views/projects/jobs/_sidebar.html.haml +++ b/app/views/projects/jobs/_sidebar.html.haml @@ -68,15 +68,8 @@ - elsif @build.runner \##{@build.runner.id} .btn-group.btn-group-justified{ role: :group } - - if @build.has_trace? - = link_to 'Raw', raw_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' - if @build.active? = link_to "Cancel", cancel_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post - - if can?(current_user, :update_build, @project) && @build.erasable? - = link_to erase_namespace_project_job_path(@project.namespace, @project, @build), - class: "btn btn-sm btn-default", method: :post, - data: { confirm: "Are you sure you want to erase this build?" } do - Erase - if @build.trigger_request .build-widget diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index 113da4d7a0c547cadf67742d486de06f0f28aea0..0d10dfcef7067fb864aa46985a0902ca2b5228f2 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -8,7 +8,7 @@ - if @build.stuck? - unless @build.any_runners_online? - .bs-callout.bs-callout-warning + .bs-callout.bs-callout-warning.js-build-stuck %p - if no_runners_for_project?(@build.project) This job is stuck, because the project doesn't have any runners online assigned to it. @@ -26,7 +26,7 @@ Runners page - if @build.starts_environment? - .prepend-top-default + .prepend-top-default.js-environment-container .environment-information - if @build.outdated_deployment? = ci_icon_for_status('success_with_warnings') @@ -47,39 +47,51 @@ - if environment.try(:last_deployment) and will overwrite the #{deployment_link(environment.last_deployment, text: 'latest deployment')} - .prepend-top-default + .prepend-top-default.js-build-erased - if @build.erased? .erased.alert.alert-warning - if @build.erased_by_user? Job has been erased by #{link_to(@build.erased_by_name, user_path(@build.erased_by))} #{time_ago_with_tooltip(@build.erased_at)} - else Job has been erased #{time_ago_with_tooltip(@build.erased_at)} - - else - #js-build-scroll.scroll-controls - .scroll-step - %a.scroll-link.scroll-top{ href: '#up-build-trace', id: 'scroll-top', title: 'Scroll to top' } - = custom_icon('scroll_up') - = custom_icon('scroll_up_hover_active') - %a.scroll-link.scroll-bottom{ href: '#down-build-trace', id: 'scroll-bottom', title: 'Scroll to bottom' } - = custom_icon('scroll_down') - = custom_icon('scroll_down_hover_active') - - if @build.active? - .autoscroll-container - %span.status-message#autoscroll-status{ data: { state: 'disabled' } } - %span.status-text Autoscroll active - %i.status-icon - = custom_icon('scroll_down_hover_active') - #up-build-trace - %pre.build-trace#build-trace + + .prepend-top-default + .build-trace-container#build-trace + .top-bar.sticky .js-truncated-info.truncated-info.hidden< Showing last %span.js-truncated-info-size.truncated-info-size>< KiB of log - - %a.js-raw-link.raw-link{ :href => raw_namespace_project_job_path(@project.namespace, @project, @build) }>< Complete Raw - %code.bash.js-build-output - .build-loader-animation.js-build-refresh + %a.js-raw-link.raw-link{ href: raw_namespace_project_job_path(@project.namespace, @project, @build) }>< Complete Raw + .controllers + - if @build.has_trace? + = link_to raw_namespace_project_job_path(@project.namespace, @project, @build), + title: 'Open raw trace', + data: { placement: 'top', container: 'body' }, + class: 'js-raw-link-controller has-tooltip' do + = icon('download') + + - if can?(current_user, :update_build, @project) && @build.erasable? + = link_to erase_namespace_project_job_path(@project.namespace, @project, @build), + method: :post, + data: { confirm: 'Are you sure you want to erase this build?', placement: 'top', container: 'body' }, + title: 'Erase Build', + class: 'has-tooltip js-erase-link' do + = icon('trash') - #down-build-trace + %button.js-scroll-up.btn-scroll.btn-transparent.btn-blank.has-tooltip{ type: 'button', + disabled: true, + title: 'Scroll Up', + data: { placement: 'top', container: 'body'} } + = custom_icon('scroll_up') + %button.js-scroll-down.btn-scroll.btn-transparent.btn-blank.has-tooltip{ type: 'button', + disabled: true, + title: 'Scroll Down', + data: { placement: 'top', container: 'body'} } + = custom_icon('scroll_down') + .bash.sticky.js-scroll-container + %code.js-build-output + .build-loader-animation.js-build-refresh = render "sidebar" diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index 502220232a18537873d71e7e6be01ec05558d19b..2cb3045f83e28924c603561145f2d1a5a754d3e2 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -5,12 +5,14 @@ - unless @project.default_issues_tracker? = content_for :sub_nav do = render "projects/merge_requests/head" -= render 'projects/last_push' - content_for :page_specific_javascripts do = webpack_bundle_tag 'common_vue' = webpack_bundle_tag 'filtered_search' + += render 'projects/last_push' + - if @project.merge_requests.exists? %div{ class: container_class } .top-area diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index 082a6bcbb2a8fbfdffecb72226a7960f5ce81652..7bde839e26f29591d858081e91d196476ad9bdfc 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -4,7 +4,8 @@ = pipeline_schedule.description %td.branch-name-cell = icon('code-fork') - = link_to pipeline_schedule.ref, project_ref_path(@project, pipeline_schedule.ref), class: "ref-name" + - if pipeline_schedule.ref + = link_to pipeline_schedule.ref, project_ref_path(@project, pipeline_schedule.ref), class: "ref-name" %td - if pipeline_schedule.last_pipeline .status-icon-container{ class: "ci-status-icon-#{pipeline_schedule.last_pipeline.status}" } diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index fd2f2c4c72c9bf094c969c0586b7e9627baa95f4..01cf2cc80e594bb9d7ff81cd7793e72a80d69ed3 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,9 +1,5 @@ - failed_builds = @pipeline.statuses.latest.failed -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('common_vue') - = page_specific_javascript_bundle_tag('pipelines_graph') - .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom %li.js-pipeline-tab-link @@ -21,7 +17,7 @@ .tab-content #js-tab-pipeline.tab-pane - #js-pipeline-graph-vue{ data: { endpoint: namespace_project_pipeline_path(@project.namespace, @project, @pipeline, format: :json) } } + #js-pipeline-graph-vue #js-tab-builds.tab-pane - if pipeline.yaml_errors.present? diff --git a/app/views/projects/pipelines/charts/_overall.haml b/app/views/projects/pipelines/charts/_overall.haml index edc4f7b079fc27457ad5865fae0e06f92ee0e253..0b7e3d22dd73de3d46a7eaa6a119bec65ce3e40f 100644 --- a/app/views/projects/pipelines/charts/_overall.haml +++ b/app/views/projects/pipelines/charts/_overall.haml @@ -2,13 +2,13 @@ %ul %li Total: - %strong= pluralize @project.builds.count(:all), 'build' + %strong= pluralize @project.builds.count(:all), 'job' %li Successful: - %strong= pluralize @project.builds.success.count(:all), 'build' + %strong= pluralize @project.builds.success.count(:all), 'job' %li Failed: - %strong= pluralize @project.builds.failed.count(:all), 'build' + %strong= pluralize @project.builds.failed.count(:all), 'job' %li Success ratio: %strong diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 49c1d88642329752938c110107f3d85563c4383a..b39453a50fbcb7e099817b557642b0cab99d9d15 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -7,3 +7,9 @@ = render "projects/pipelines/info" = render "projects/pipelines/with_tabs", pipeline: @pipeline + +.js-pipeline-details-vue{ data: { endpoint: namespace_project_pipeline_path(@project.namespace, @project, @pipeline, format: :json) } } + +- content_for :page_specific_javascripts do + = webpack_bundle_tag('common_vue') + = webpack_bundle_tag('pipelines_details') diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index b51955010ce9cb4545c3a845e0e99c9bcaa52285..f7e410e27b8d9e79a11459bb06b43c05b1c59e3a 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -4,6 +4,7 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, namespace_project_commits_url(@project.namespace, @project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits") = render "projects/commits/head" + = render 'projects/last_push' %div{ class: container_class } diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index 059a0d1ac789c79f7cdad9aad8ed8505602f1f0d..314d8e9cb25ba25315030ceed41562130d1e8ae6 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -3,41 +3,48 @@ .fade-right= icon('angle-right') %ul.nav-links.search-filter.scrolling-tabs - if @project - %li{ class: active_when(@scope == 'blobs') } - = link_to search_filter_path(scope: 'blobs') do - Code - %span.badge - = @search_results.blobs_count - %li{ class: active_when(@scope == 'issues') } - = link_to search_filter_path(scope: 'issues') do - Issues - %span.badge - = @search_results.issues_count - %li{ class: active_when(@scope == 'merge_requests') } - = link_to search_filter_path(scope: 'merge_requests') do - Merge requests - %span.badge - = @search_results.merge_requests_count - %li{ class: active_when(@scope == 'milestones') } - = link_to search_filter_path(scope: 'milestones') do - Milestones - %span.badge - = @search_results.milestones_count - %li{ class: active_when(@scope == 'notes') } - = link_to search_filter_path(scope: 'notes') do - Comments - %span.badge - = @search_results.notes_count - %li{ class: active_when(@scope == 'wiki_blobs') } - = link_to search_filter_path(scope: 'wiki_blobs') do - Wiki - %span.badge - = @search_results.wiki_blobs_count - %li{ class: active_when(@scope == 'commits') } - = link_to search_filter_path(scope: 'commits') do - Commits - %span.badge - = @search_results.commits_count + - if project_search_tabs?(:blobs) + %li{ class: active_when(@scope == 'blobs') } + = link_to search_filter_path(scope: 'blobs') do + Code + %span.badge + = @search_results.blobs_count + - if project_search_tabs?(:issues) + %li{ class: active_when(@scope == 'issues') } + = link_to search_filter_path(scope: 'issues') do + Issues + %span.badge + = @search_results.issues_count + - if project_search_tabs?(:merge_requests) + %li{ class: active_when(@scope == 'merge_requests') } + = link_to search_filter_path(scope: 'merge_requests') do + Merge requests + %span.badge + = @search_results.merge_requests_count + - if project_search_tabs?(:milestones) + %li{ class: active_when(@scope == 'milestones') } + = link_to search_filter_path(scope: 'milestones') do + Milestones + %span.badge + = @search_results.milestones_count + - if project_search_tabs?(:notes) + %li{ class: active_when(@scope == 'notes') } + = link_to search_filter_path(scope: 'notes') do + Comments + %span.badge + = @search_results.notes_count + - if project_search_tabs?(:wiki) + %li{ class: active_when(@scope == 'wiki_blobs') } + = link_to search_filter_path(scope: 'wiki_blobs') do + Wiki + %span.badge + = @search_results.wiki_blobs_count + - if project_search_tabs?(:commits) + %li{ class: active_when(@scope == 'commits') } + = link_to search_filter_path(scope: 'commits') do + Commits + %span.badge + = @search_results.commits_count - elsif @show_snippets %li{ class: active_when(@scope == 'snippet_blobs') } diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml index fbbf6f358c5e5a7df1d61b0aaa8c85c466ba6a3f..9ed844cf5e71b4771e2eebf9c4be5c41c1d31914 100644 --- a/app/views/shared/_new_project_item_select.html.haml +++ b/app/views/shared/_new_project_item_select.html.haml @@ -1,6 +1,6 @@ - if @projects.any? .project-item-select-holder - = project_select_tag :project_path, class: "project-item-select", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at' } + = project_select_tag :project_path, class: "project-item-select", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at' }, with_feature_enabled: local_assigns[:with_feature_enabled] %a.btn.btn-new.new-project-item-select-button = local_assigns[:label] = icon('caret-down') diff --git a/app/views/shared/icons/_scroll_down.svg b/app/views/shared/icons/_scroll_down.svg index acf22ac9314f19207ff6a66f2305f1a1db27451e..1d22870ec09ecf58a22a0b7d4ab6e3bd51c9395a 100644 --- a/app/views/shared/icons/_scroll_down.svg +++ b/app/views/shared/icons/_scroll_down.svg @@ -1,3 +1,5 @@ -<svg width="16" height="33" class="gitlab-icon-scroll-down" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg"> - <path fill="#ffffff" d="M1.385 5.534v12.47a4.145 4.145 0 0 0 4.144 4.15h4.942a4.151 4.151 0 0 0 4.144-4.15V5.535a4.145 4.145 0 0 0-4.144-4.15H5.53a4.151 4.151 0 0 0-4.144 4.15zM8.88 30.27v-4.351a.688.688 0 0 0-.69-.688.687.687 0 0 0-.69.688v4.334l-1.345-1.346a.69.69 0 0 0-.976.976l2.526 2.526a.685.685 0 0 0 .494.2.685.685 0 0 0 .493-.2l2.526-2.526a.69.69 0 1 0-.976-.976L8.88 30.27zM0 5.534A5.536 5.536 0 0 1 5.529 0h4.942A5.53 5.53 0 0 1 16 5.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 18.005V5.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0V6.544z" fill-rule="evenodd"/> +<svg width="12" height="16" viewBox="0 0 12 16" xmlns="http://www.w3.org/2000/svg"> + <path class="first-triangle" d="M1.048 14.155a.508.508 0 0 0-.32.105c-.091.07-.136.154-.136.25v.71c0 .095.045.178.135.249.09.07.197.105.321.105h10.043c.124 0 .23-.035.321-.105.09-.07.136-.154.136-.25v-.71c0-.095-.045-.178-.136-.249a.508.508 0 0 0-.32-.105"/> + <path class="second-triangle" d="M.687 8.027c-.09-.087-.122-.16-.093-.22.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 12.91a.458.458 0 0 1-.136.089h-.37a.626.626 0 0 1-.136-.09"/> + <path class="third-triangle" d="M.687 1.027C.597.94.565.867.594.807c.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 5.91A.458.458 0 0 1 6.257 6h-.37a.626.626 0 0 1-.136-.09"/> </svg> diff --git a/app/views/shared/icons/_scroll_down_hover_active.svg b/app/views/shared/icons/_scroll_down_hover_active.svg deleted file mode 100644 index 262576acf54249fe585a74e9c79d41537253efe6..0000000000000000000000000000000000000000 --- a/app/views/shared/icons/_scroll_down_hover_active.svg +++ /dev/null @@ -1,3 +0,0 @@ -<svg width="16" height="33" class="gitlab-icon-scroll-down-hover" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg"> - <path fill="#ffffff" d="M8.88 30.27v-4.351a.688.688 0 0 0-.69-.688.687.687 0 0 0-.69.688v4.334l-1.345-1.346a.69.69 0 0 0-.976.976l2.526 2.526a.685.685 0 0 0 .494.2.685.685 0 0 0 .493-.2l2.526-2.526a.69.69 0 1 0-.976-.976L8.88 30.27zM0 5.534A5.536 5.536 0 0 1 5.529 0h4.942A5.53 5.53 0 0 1 16 5.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 18.005V5.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0V6.544z" fill-rule="evenodd"/> -</svg> diff --git a/app/views/shared/icons/_scroll_up.svg b/app/views/shared/icons/_scroll_up.svg index f11288fd59c1c9f8d667b3b9bf3f308a68cdcd3d..70b1e4d9c9189034af99015e5637bda93d140544 100644 --- a/app/views/shared/icons/_scroll_up.svg +++ b/app/views/shared/icons/_scroll_up.svg @@ -1,3 +1 @@ -<svg width="16" height="33" class="gitlab-icon-scroll-up" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg"> - <path fill="#ffffff" d="M1.385 14.534v12.47a4.145 4.145 0 0 0 4.144 4.15h4.942a4.151 4.151 0 0 0 4.144-4.15v-12.47a4.145 4.145 0 0 0-4.144-4.15H5.53a4.151 4.151 0 0 0-4.144 4.15zM8.88 2.609V6.96a.688.688 0 0 1-.69.688.687.687 0 0 1-.69-.688V2.627L6.155 3.972a.69.69 0 0 1-.976-.976L7.705.47a.685.685 0 0 1 .494-.2.685.685 0 0 1 .493.2l2.526 2.526a.69.69 0 1 1-.976.976L8.88 2.609zM0 14.534A5.536 5.536 0 0 1 5.529 9h4.942A5.53 5.53 0 0 1 16 14.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 27.005V14.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0v-2.143z" fill-rule="evenodd"/> -</svg> +<svg width="12" height="16" viewBox="0 0 12 16" xmlns="http://www.w3.org/2000/svg"><path d="M1.048 1.845a.508.508 0 0 1-.32-.105c-.091-.07-.136-.154-.136-.25V.78c0-.095.045-.178.135-.249a.508.508 0 0 1 .321-.105h10.043c.124 0 .23.035.321.105.09.07.136.154.136.25v.71c0 .095-.045.178-.136.249a.508.508 0 0 1-.32.105"/><path d="M.687 7.973c-.09.087-.122.16-.093.22.028.06.104.09.228.09h10.5c.123 0 .2-.03.228-.09.029-.06-.002-.133-.093-.22L6.393 3.09A.458.458 0 0 0 6.257 3h-.37a.626.626 0 0 0-.136.09"/><path d="M.687 14.973c-.09.087-.122.16-.093.22.028.06.104.09.228.09h10.5c.123 0 .2-.03.228-.09.029-.06-.002-.133-.093-.22L6.393 10.09A.458.458 0 0 0 6.257 10h-.37a.626.626 0 0 0-.136.09"/></svg> diff --git a/app/views/shared/icons/_scroll_up_hover_active.svg b/app/views/shared/icons/_scroll_up_hover_active.svg deleted file mode 100644 index 4658dbb1bb7ff9a637c13fdd904325307a992805..0000000000000000000000000000000000000000 --- a/app/views/shared/icons/_scroll_up_hover_active.svg +++ /dev/null @@ -1,3 +0,0 @@ -<svg width="16" height="33" class="gitlab-icon-scroll-up-hover" viewBox="0 0 16 33" xmlns="http://www.w3.org/2000/svg"> - <path fill="#ffffff" d="M8.88 2.646l1.362 1.362a.69.69 0 0 0 .976-.976L8.692.507A.685.685 0 0 0 8.2.306a.685.685 0 0 0-.494.2L5.179 3.033a.69.69 0 1 0 .976.976L7.5 2.663v4.179c0 .38.306.688.69.688.381 0 .69-.306.69-.688V2.646zM0 14.534A5.536 5.536 0 0 1 5.529 9h4.942A5.53 5.53 0 0 1 16 14.534v12.47a5.536 5.536 0 0 1-5.529 5.534H5.53A5.53 5.53 0 0 1 0 27.005V14.534zm7 1.01a1 1 0 1 1 2 0v2.143a1 1 0 1 1-2 0v-2.143z" fill-rule="evenodd"/> -</svg> diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml index 93c7fa0c7d65d29d32048c4002547a0b60cd14d3..1cf662e29c4ce4e04c585f74c4da80c698a04ffa 100644 --- a/app/views/shared/issuable/_label_dropdown.html.haml +++ b/app/views/shared/issuable/_label_dropdown.html.haml @@ -9,7 +9,7 @@ - selected = local_assigns.fetch(:selected, nil) - selected_toggle = local_assigns.fetch(:selected_toggle, nil) - dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label") -- dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path), labels: labels_filter_path, default_label: "Labels"} +- dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), labels: labels_filter_path, default_label: "Labels"} - dropdown_data.merge!(data_options) - classes << 'js-extra-options' if extra_options - classes << 'js-filter-submit' if filter_submit diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index d36707dd0426f3d6f75203a2d40ce88be38672de..f8d755b6961503607f461c0b782a683246f3adc5 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -157,7 +157,8 @@ $(document).off('page:restore').on('page:restore', function (event) { if (gl.FilteredSearchManager) { - new gl.FilteredSearchManager(); + const filteredSearchManager = new gl.FilteredSearchManager(); + filteredSearchManager.setup(); } Issuable.init(); new gl.IssuableBulkActions({ diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index 26567c08eb6f0723de21722dee701604283da276..bcfa1dc826e6bc2b02ee68b6c8dda48cf62bc06f 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -32,7 +32,7 @@ .selectbox.hide-collapsed - issuable.assignees.each do |assignee| - = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username } - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } } diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index d23f79be2be9448f3d53571288b5bc19fc4a4062..271150ed318d11ff7c082718a5badab62e5ee00b 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -5,3 +5,13 @@ -# This check is duplicated below, to avoid conflicts with EE. - return unless issuable.can_remove_source_branch?(current_user) + +.form-group + .col-sm-10.col-sm-offset-2 + - if issuable.can_remove_source_branch?(current_user) + .checkbox + - initial_checkbox_value = issuable.merge_params.key?('force_remove_source_branch') ? issuable.force_remove_source_branch? : true + = label_tag 'merge_request[force_remove_source_branch]' do + = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil + = check_box_tag 'merge_request[force_remove_source_branch]', '1', initial_checkbox_value + Remove source branch when merge request is accepted. diff --git a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml index 8119f19291b9be8a438bbeed0f34ba1df8c150fe..77175c839a60bfb4d457f8fa058a30a3aaab99dd 100644 --- a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml +++ b/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml @@ -2,7 +2,7 @@ .col-sm-10{ class: ("col-lg-8" if has_due_date) } .issuable-form-select-holder.selectbox - issuable.assignees.each do |assignee| - = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name } + = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { meta: assignee.name, avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username } - if issuable.assignees.length === 0 = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' } diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index 785b1b22a4971204b6cff9c261bbf87611050088..5902798dfd06119d5957412a7866969cda71e707 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -3,24 +3,23 @@ = render 'shared/notes/edit_form', project: @project -%ul.notes.notes-form.timeline - %li.timeline-entry - .flash-container.timeline-content +- if can_create_note? + %ul.notes.notes-form.timeline + %li.timeline-entry + .flash-container.timeline-content - - if can_create_note? .timeline-icon.hidden-xs.hidden-sm %a.author_link{ href: user_path(current_user) } = image_tag avatar_icon(current_user), alt: current_user.to_reference, class: 'avatar s40' .timeline-content.timeline-content-form = render "shared/notes/form", view: diff_view - - elsif !current_user - .disabled-comment.text-center - .disabled-comment-text.inline - Please - = link_to "register", new_session_path(:user, redirect_to_referer: 'yes') - or - = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes') - to post a comment +- elsif !current_user + .disabled-comment.text-center.prepend-top-default + Please + = link_to "register", new_session_path(:user, redirect_to_referer: 'yes') + or + = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes') + to comment :javascript var notes = new Notes("#{notes_url}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}", #{autocomplete}) diff --git a/changelogs/unreleased/19107-404-when-creating-new-milestone-or-issue-for-project-that-has-issues-disabled.yml b/changelogs/unreleased/19107-404-when-creating-new-milestone-or-issue-for-project-that-has-issues-disabled.yml new file mode 100644 index 0000000000000000000000000000000000000000..bec9aa34761374dbf45ea0e5067892e5a6336b19 --- /dev/null +++ b/changelogs/unreleased/19107-404-when-creating-new-milestone-or-issue-for-project-that-has-issues-disabled.yml @@ -0,0 +1,4 @@ +--- +title: 'New issue'/'New merge request' dropdowns should show only projects with issues/merge requests feature enabled +merge_request: 19107 +author: blackst0ne diff --git a/changelogs/unreleased/23036-replace-all-spinach-tests-with-rspec-feature-tests.yml b/changelogs/unreleased/23036-replace-all-spinach-tests-with-rspec-feature-tests.yml new file mode 100644 index 0000000000000000000000000000000000000000..b350b27d863a4dd8f192ccbbcb31ac4c467cb9d0 --- /dev/null +++ b/changelogs/unreleased/23036-replace-all-spinach-tests-with-rspec-feature-tests.yml @@ -0,0 +1,4 @@ +--- +title: Replace 'starred_projects.feature' spinach test with an rspec analog +merge_request: 11752 +author: blackst0ne diff --git a/changelogs/unreleased/25373-jira-links.yml b/changelogs/unreleased/25373-jira-links.yml new file mode 100644 index 0000000000000000000000000000000000000000..09589d4b99294f9e11fef4ce4afeafa6b5403170 --- /dev/null +++ b/changelogs/unreleased/25373-jira-links.yml @@ -0,0 +1,4 @@ +--- +title: Don’t create comment on JIRA if it already exists for the entity +merge_request: +author: diff --git a/changelogs/unreleased/27439-memory-usage-info.yml b/changelogs/unreleased/27439-memory-usage-info.yml new file mode 100644 index 0000000000000000000000000000000000000000..dd212853f57233bad045fbc9abeb548ce80b590a --- /dev/null +++ b/changelogs/unreleased/27439-memory-usage-info.yml @@ -0,0 +1,4 @@ +--- +title: Add performance deltas between app deployments on Merge Request widget +merge_request: 11730 +author: diff --git a/changelogs/unreleased/30410-revert-9347-and-10079.yml b/changelogs/unreleased/30410-revert-9347-and-10079.yml new file mode 100644 index 0000000000000000000000000000000000000000..0149209caf25a01d9b3c3763546e481ed317edc4 --- /dev/null +++ b/changelogs/unreleased/30410-revert-9347-and-10079.yml @@ -0,0 +1,5 @@ +--- +title: Revert the feature that would include the current user's username in the HTTP + clone URL +merge_request: 11792 +author: diff --git a/changelogs/unreleased/30917-wiki-is-not-searchable-with-guest-permissions.yml b/changelogs/unreleased/30917-wiki-is-not-searchable-with-guest-permissions.yml new file mode 100644 index 0000000000000000000000000000000000000000..c9bd2dc465e108c0844f95b94fc0c06457487241 --- /dev/null +++ b/changelogs/unreleased/30917-wiki-is-not-searchable-with-guest-permissions.yml @@ -0,0 +1,4 @@ +--- +title: 'Fix: Wiki is not searchable with Guest permissions' +merge_request: +author: diff --git a/changelogs/unreleased/31448-jira-urls.yml b/changelogs/unreleased/31448-jira-urls.yml new file mode 100644 index 0000000000000000000000000000000000000000..d0e39f61b55599191560db697c1443cd02ddf0dc --- /dev/null +++ b/changelogs/unreleased/31448-jira-urls.yml @@ -0,0 +1,4 @@ +--- +title: Add API URL to JIRA settings +merge_request: +author: diff --git a/changelogs/unreleased/31616-add-uptime-of-gitlab-instance-in-admin-area.yml b/changelogs/unreleased/31616-add-uptime-of-gitlab-instance-in-admin-area.yml new file mode 100644 index 0000000000000000000000000000000000000000..6dc48d6b2d8be85a9c44e1f85b9500df0b75e5fa --- /dev/null +++ b/changelogs/unreleased/31616-add-uptime-of-gitlab-instance-in-admin-area.yml @@ -0,0 +1,4 @@ +--- +title: Add server uptime to System Info page in admin dashboard +merge_request: 11590 +author: Justin Boltz diff --git a/changelogs/unreleased/31849-pipeline-show-view-realtime.yml b/changelogs/unreleased/31849-pipeline-show-view-realtime.yml new file mode 100644 index 0000000000000000000000000000000000000000..838a769a26ed870f0bc5ae6c915bc7ef38e6e1ad --- /dev/null +++ b/changelogs/unreleased/31849-pipeline-show-view-realtime.yml @@ -0,0 +1,5 @@ +--- +title: Creates a mediator for pipeline details vue in order to mount several vue apps + with the same data +merge_request: +author: diff --git a/changelogs/unreleased/32790-pipeline_schedules-pages-throwing-error-500.yml b/changelogs/unreleased/32790-pipeline_schedules-pages-throwing-error-500.yml new file mode 100644 index 0000000000000000000000000000000000000000..a58f3a7429e36e412ba74c9169a4166470a7f74c --- /dev/null +++ b/changelogs/unreleased/32790-pipeline_schedules-pages-throwing-error-500.yml @@ -0,0 +1,4 @@ +--- +title: Fix pipeline_schedules pages throwing error 500 +merge_request: 11706 +author: dosuken123 diff --git a/changelogs/unreleased/32851-postgres-min-version.yml b/changelogs/unreleased/32851-postgres-min-version.yml new file mode 100644 index 0000000000000000000000000000000000000000..139307d65c6ae216da850a7932f3b67328689e2d --- /dev/null +++ b/changelogs/unreleased/32851-postgres-min-version.yml @@ -0,0 +1,4 @@ +--- +title: Minimum postgresql version is now 9.2 +merge_request: 11677 +author: diff --git a/changelogs/unreleased/33032-invalid-you-directly-addressed-yourself-todo-when-using-unsubscribe.yml b/changelogs/unreleased/33032-invalid-you-directly-addressed-yourself-todo-when-using-unsubscribe.yml new file mode 100644 index 0000000000000000000000000000000000000000..1eaa0d0124e828fa07b059decdd8985af3241c7a --- /dev/null +++ b/changelogs/unreleased/33032-invalid-you-directly-addressed-yourself-todo-when-using-unsubscribe.yml @@ -0,0 +1,5 @@ +--- +title: Fix /unsubscribe slash command creating extra todos when you were already mentioned + in an issue +merge_request: +author: diff --git a/changelogs/unreleased/33048-markdown-rendering-of-md-files-has-ceased-to-display-latex-equations.yml b/changelogs/unreleased/33048-markdown-rendering-of-md-files-has-ceased-to-display-latex-equations.yml new file mode 100644 index 0000000000000000000000000000000000000000..5648e013e75e72db4031a62ccf9360ec673d1860 --- /dev/null +++ b/changelogs/unreleased/33048-markdown-rendering-of-md-files-has-ceased-to-display-latex-equations.yml @@ -0,0 +1,4 @@ +--- +title: Fix math rendering on blob pages +merge_request: +author: diff --git a/changelogs/unreleased/ci-build-pipeline-header-vue.yml b/changelogs/unreleased/ci-build-pipeline-header-vue.yml new file mode 100644 index 0000000000000000000000000000000000000000..2bbff2fdd161cb02e9a92ce66e5afbfb3f9b4fcf --- /dev/null +++ b/changelogs/unreleased/ci-build-pipeline-header-vue.yml @@ -0,0 +1,4 @@ +--- +title: Creates CI Header component for Pipelines and Jobs details pages +merge_request: +author: diff --git a/changelogs/unreleased/dm-consistent-last-push-event.yml b/changelogs/unreleased/dm-consistent-last-push-event.yml new file mode 100644 index 0000000000000000000000000000000000000000..acc17cb45236bb742330395a62a73345dae9d7e9 --- /dev/null +++ b/changelogs/unreleased/dm-consistent-last-push-event.yml @@ -0,0 +1,4 @@ +--- +title: Consistently display last push event widget +merge_request: +author: diff --git a/changelogs/unreleased/dm-oauth-config-for.yml b/changelogs/unreleased/dm-oauth-config-for.yml new file mode 100644 index 0000000000000000000000000000000000000000..8fbbd45bb57f2bda25852ff68774f84e295b22ca --- /dev/null +++ b/changelogs/unreleased/dm-oauth-config-for.yml @@ -0,0 +1,4 @@ +--- +title: Return nil when looking up config for unknown LDAP provider +merge_request: +author: diff --git a/changelogs/unreleased/fix-counter-cache-for-acts-as-taggable.yml b/changelogs/unreleased/fix-counter-cache-for-acts-as-taggable.yml new file mode 100644 index 0000000000000000000000000000000000000000..e40668546c0d05b33d898995cf499dcbabdf748a --- /dev/null +++ b/changelogs/unreleased/fix-counter-cache-for-acts-as-taggable.yml @@ -0,0 +1,4 @@ +--- +title: Fix counter cache for acts as taggable +merge_request: +author: diff --git a/changelogs/unreleased/gitaly-opt-out.yml b/changelogs/unreleased/gitaly-opt-out.yml new file mode 100644 index 0000000000000000000000000000000000000000..2f89e0bfc9ac271f238bfac37f4008ecdbd9d3ce --- /dev/null +++ b/changelogs/unreleased/gitaly-opt-out.yml @@ -0,0 +1,4 @@ +--- +title: Enable Gitaly by default in installations from source +merge_request: 11796 +author: diff --git a/changelogs/unreleased/issuable-form-create-label-sub-groups.yml b/changelogs/unreleased/issuable-form-create-label-sub-groups.yml new file mode 100644 index 0000000000000000000000000000000000000000..54b818d6d5e34e23fbf2baa37908d9de14cb7e45 --- /dev/null +++ b/changelogs/unreleased/issuable-form-create-label-sub-groups.yml @@ -0,0 +1,4 @@ +--- +title: Fixed create new label form in issue form not working for sub-group projects +merge_request: +author: diff --git a/changelogs/unreleased/issue_32225_2.yml b/changelogs/unreleased/issue_32225_2.yml new file mode 100644 index 0000000000000000000000000000000000000000..320b9fe00b8a1102d37cd5dabd6d283be8a3a8bd --- /dev/null +++ b/changelogs/unreleased/issue_32225_2.yml @@ -0,0 +1,4 @@ +--- +title: Handle head pipeline when creating merge requests +merge_request: +author: diff --git a/changelogs/unreleased/rework-authorizations-performance.yml b/changelogs/unreleased/rework-authorizations-performance.yml new file mode 100644 index 0000000000000000000000000000000000000000..f64257a6f56c65301cda516f5f987ddf505f5b31 --- /dev/null +++ b/changelogs/unreleased/rework-authorizations-performance.yml @@ -0,0 +1,6 @@ +--- +title: > + Project authorizations are calculated much faster when using PostgreSQL, and + nested groups support for MySQL has been removed +merge_request: 10885 +author: diff --git a/changelogs/unreleased/sh-fix-submodules-trailing-spaces.yml b/changelogs/unreleased/sh-fix-submodules-trailing-spaces.yml new file mode 100644 index 0000000000000000000000000000000000000000..d633995d4676eb0706fe6c63654d1e962ef77783 --- /dev/null +++ b/changelogs/unreleased/sh-fix-submodules-trailing-spaces.yml @@ -0,0 +1,4 @@ +--- +title: Strip trailing whitespaces in submodule URLs +merge_request: +author: diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index a727f7e2fa349e9bdd28fddc048059e20540d38c..6c1c1f8c041000f2a243cf3bde26569293ad4a6e 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -449,7 +449,7 @@ production: &base # This setting controls whether GitLab uses Gitaly (new component # introduced in 9.0). Eventually Gitaly use will become mandatory and # this option will disappear. - enabled: false + enabled: true # # 4. Advanced settings diff --git a/config/initializers/acts_as_taggable.rb b/config/initializers/0_acts_as_taggable.rb similarity index 63% rename from config/initializers/acts_as_taggable.rb rename to config/initializers/0_acts_as_taggable.rb index c564c0cab11342ae8f7f2977cedd13c44667b73d..54e9fcc31dbf72eebb5dc175d158f4e71ce1d5c2 100644 --- a/config/initializers/acts_as_taggable.rb +++ b/config/initializers/0_acts_as_taggable.rb @@ -3,3 +3,7 @@ ActsAsTaggableOn.strict_case_match = true # tags_counter enables caching count of tags which results in an update whenever a tag is added or removed # since the count is not used anywhere its better performance wise to disable this cache ActsAsTaggableOn.tags_counter = false + +# validate that counter cache is disabled +raise "Counter cache is not disabled" if + ActsAsTaggableOn::Tagging.reflections["tag"].options[:counter_cache] diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 4fb4baf631f9ef8479b26d333f5db7ff5ebf842e..45ea2040d23f06af85195149944047718d447f17 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -482,7 +482,7 @@ Settings.rack_attack.git_basic_auth['bantime'] ||= 1.hour # Gitaly # Settings['gitaly'] ||= Settingslogic.new({}) -Settings.gitaly['enabled'] ||= false +Settings.gitaly['enabled'] = true if Settings.gitaly['enabled'].nil? # # Webpack settings diff --git a/config/initializers/ar_speed_up_migration_checking.rb b/config/initializers/ar_speed_up_migration_checking.rb index 1fe5defc01dc8da1ea05d769e1f9f6a5306da48a..aae774daa3517f31eb4671d85bf82e40cd81c8f1 100644 --- a/config/initializers/ar_speed_up_migration_checking.rb +++ b/config/initializers/ar_speed_up_migration_checking.rb @@ -10,7 +10,7 @@ if Rails.env.test? # it reads + parses `db/migrate/*` each time. Memoizing it can save 0.5 # seconds per spec. def migrations(paths) - @migrations ||= migrations_unmemoized(paths) + (@migrations ||= migrations_unmemoized(paths)).dup end end end diff --git a/config/initializers/postgresql_cte.rb b/config/initializers/postgresql_cte.rb new file mode 100644 index 0000000000000000000000000000000000000000..7f0df8949dbf11b43911add85e256732977a8875 --- /dev/null +++ b/config/initializers/postgresql_cte.rb @@ -0,0 +1,132 @@ +# Adds support for WITH statements when using PostgreSQL. The code here is taken +# from https://github.com/shmay/ctes_in_my_pg which at the time of writing has +# not been pushed to RubyGems. The license of this repository is as follows: +# +# The MIT License (MIT) +# +# Copyright (c) 2012 Dan McClain +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +module ActiveRecord + class Relation + class Merger # :nodoc: + def normal_values + NORMAL_VALUES + [:with] + end + end + end +end + +module ActiveRecord::Querying + delegate :with, to: :all +end + +module ActiveRecord + class Relation + # WithChain objects act as placeholder for queries in which #with does not have any parameter. + # In this case, #with must be chained with #recursive to return a new relation. + class WithChain + def initialize(scope) + @scope = scope + end + + # Returns a new relation expressing WITH RECURSIVE + def recursive(*args) + @scope.with_values += args + @scope.recursive_value = true + @scope + end + end + + def with_values + @values[:with] || [] + end + + def with_values=(values) + raise ImmutableRelation if @loaded + @values[:with] = values + end + + def recursive_value=(value) + raise ImmutableRelation if @loaded + @values[:recursive] = value + end + + def recursive_value + @values[:recursive] + end + + def with(opts = :chain, *rest) + if opts == :chain + WithChain.new(spawn) + elsif opts.blank? + self + else + spawn.with!(opts, *rest) + end + end + + def with!(opts = :chain, *rest) # :nodoc: + if opts == :chain + WithChain.new(self) + else + self.with_values += [opts] + rest + self + end + end + + def build_arel + arel = super() + + build_with(arel) if @values[:with] + + arel + end + + def build_with(arel) + with_statements = with_values.flat_map do |with_value| + case with_value + when String + with_value + when Hash + with_value.map do |name, expression| + case expression + when String + select = Arel::Nodes::SqlLiteral.new "(#{expression})" + when ActiveRecord::Relation, Arel::SelectManager + select = Arel::Nodes::SqlLiteral.new "(#{expression.to_sql})" + end + Arel::Nodes::As.new Arel::Nodes::SqlLiteral.new("\"#{name}\""), select + end + when Arel::Nodes::As + with_value + end + end + + unless with_statements.empty? + if recursive_value + arel.with :recursive, with_statements + else + arel.with with_statements + end + end + end + end +end diff --git a/config/initializers/server_uptime.rb b/config/initializers/server_uptime.rb new file mode 100644 index 0000000000000000000000000000000000000000..46bf242e1430c258ef401a6fa231aa888d9a5865 --- /dev/null +++ b/config/initializers/server_uptime.rb @@ -0,0 +1 @@ +Rails.application.config.booted_at = Time.now diff --git a/config/webpack.config.js b/config/webpack.config.js index 2ada74e5a55b4629471c5c73372e411cc4377236..c77b1d6334cfbdeea72ad81803cc81402aa2df22 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -24,6 +24,7 @@ var config = { }, context: path.join(ROOT_PATH, 'app/assets/javascripts'), entry: { + balsamiq_viewer: './blob/balsamiq_viewer.js', blob: './blob_edit/blob_bundle.js', boards: './boards/boards_bundle.js', common: './commons/index.js', @@ -48,8 +49,7 @@ var config = { notebook_viewer: './blob/notebook_viewer.js', pdf_viewer: './blob/pdf_viewer.js', pipelines: './pipelines/index.js', - balsamiq_viewer: './blob/balsamiq_viewer.js', - pipelines_graph: './pipelines/graph_bundle.js', + pipelines_details: './pipelines/pipeline_details_bundle.js', profile: './profile/profile_bundle.js', protected_branches: './protected_branches/protected_branches_bundle.js', protected_tags: './protected_tags', @@ -102,7 +102,7 @@ var config = { loader: 'file-loader', }, { - test: /locale\/[a-z]+\/(.*)\.js$/, + test: /locale\/\w+\/(.*)\.js$/, loader: 'exports-loader?locales', }, ] @@ -160,7 +160,7 @@ var config = { 'notebook_viewer', 'pdf_viewer', 'pipelines', - 'pipelines_graph', + 'pipelines_details', 'schedule_form', 'schedules_index', 'sidebar', diff --git a/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb b/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb index bd0463886bc0a46bae6d07837cbe048f3f9d40e8..4d6a61bd6142404ae8912a20aabaa02327c94160 100644 --- a/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb +++ b/db/migrate/20160615191922_set_missing_stage_on_ci_builds.rb @@ -1,3 +1,4 @@ +# rubocop:disable Migration/UpdateColumnInBatches class SetMissingStageOnCiBuilds < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb b/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb index 1eb99feb40c9ef3d07ba11f69cfe16f384d653fe..b2a2ce41391092ccb5e6b036d8bf68a1e90c6334 100644 --- a/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb +++ b/db/migrate/20160721081015_drop_and_readd_has_external_wiki_in_projects.rb @@ -1,3 +1,4 @@ +# rubocop:disable Migration/UpdateColumnInBatches class DropAndReaddHasExternalWikiInProjects < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb b/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb index f1a1f001cb303d7908cf99fc40bbbcdfca5f1b9c..febd2c0e65ebce90e7967f8abea75bb355c0ffec 100644 --- a/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb +++ b/db/migrate/20160901141443_set_confidential_issues_events_on_webhooks.rb @@ -1,3 +1,4 @@ +# rubocop:disable Migration/UpdateColumnInBatches class SetConfidentialIssuesEventsOnWebhooks < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20160919144305_add_type_to_labels.rb b/db/migrate/20160919144305_add_type_to_labels.rb index 66172bda6ffc92f967ea0f0d52289a60916bf754..2d2725ccf5900d7af2fb4a2e431e2290660ac72b 100644 --- a/db/migrate/20160919144305_add_type_to_labels.rb +++ b/db/migrate/20160919144305_add_type_to_labels.rb @@ -1,3 +1,4 @@ +# rubocop:disable Migration/UpdateColumnInBatches class AddTypeToLabels < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20161018124658_make_project_owners_masters.rb b/db/migrate/20161018124658_make_project_owners_masters.rb index a576bb7b6222920b939b928a543835b7099a63b8..fe11699c196ff0d4010a5d7387f1737a9f672069 100644 --- a/db/migrate/20161018124658_make_project_owners_masters.rb +++ b/db/migrate/20161018124658_make_project_owners_masters.rb @@ -1,3 +1,4 @@ +# rubocop:disable Migration/UpdateColumnInBatches class MakeProjectOwnersMasters < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb b/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb index 50ad74372274219f2f4f9861c0266d3eff343e46..c7cada6dfc523f9f997e11e1b89d297d3a2cff82 100644 --- a/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb +++ b/db/migrate/20161227192806_rename_slack_and_mattermost_notification_services.rb @@ -1,3 +1,4 @@ +# rubocop:disable Migration/UpdateColumnInBatches class RenameSlackAndMattermostNotificationServices < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20170320173259_migrate_assignees.rb b/db/migrate/20170320173259_migrate_assignees.rb index 23e7500a32d808429144ee4165fc3d471a9c52ef..7b61e811317b51ebe8c97c7f715ba2b10318910b 100644 --- a/db/migrate/20170320173259_migrate_assignees.rb +++ b/db/migrate/20170320173259_migrate_assignees.rb @@ -1,6 +1,4 @@ -# See http://doc.gitlab.com/ce/development/migration_style_guide.html -# for more information on how to write migrations for GitLab. - +# rubocop:disable Migration/UpdateColumnInBatches class MigrateAssignees < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/migrate/20170503140201_reschedule_project_authorizations.rb b/db/migrate/20170503140201_reschedule_project_authorizations.rb new file mode 100644 index 0000000000000000000000000000000000000000..fa45adadbae80f3f21760e75e123b39952833448 --- /dev/null +++ b/db/migrate/20170503140201_reschedule_project_authorizations.rb @@ -0,0 +1,44 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RescheduleProjectAuthorizations < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + class User < ActiveRecord::Base + self.table_name = 'users' + end + + def up + offset = 0 + batch = 5000 + start = Time.now + + loop do + relation = User.where('id > ?', offset) + user_ids = relation.limit(batch).reorder(id: :asc).pluck(:id) + + break if user_ids.empty? + + offset = user_ids.last + + # This will schedule each batch 5 minutes after the previous batch was + # scheduled. This smears out the load over time, instead of immediately + # scheduling a million jobs. + Sidekiq::Client.push_bulk( + 'queue' => 'authorized_projects', + 'args' => user_ids.zip, + 'class' => 'AuthorizedProjectsWorker', + 'at' => start.to_i + ) + + start += 5.minutes + end + end + + def down + end +end diff --git a/db/migrate/20170503140202_turn_nested_groups_into_regular_groups_for_mysql.rb b/db/migrate/20170503140202_turn_nested_groups_into_regular_groups_for_mysql.rb new file mode 100644 index 0000000000000000000000000000000000000000..c67690642c98b2a450c5e6405f4f3c7e32ab84a3 --- /dev/null +++ b/db/migrate/20170503140202_turn_nested_groups_into_regular_groups_for_mysql.rb @@ -0,0 +1,123 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +# This migration depends on code external to it. For example, it relies on +# updating a namespace to also rename directories (uploads, GitLab pages, etc). +# The alternative is to copy hundreds of lines of code into this migration, +# adjust them where needed, etc; something which doesn't work well at all. +class TurnNestedGroupsIntoRegularGroupsForMysql < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def run_migration? + Gitlab::Database.mysql? + end + + def up + return unless run_migration? + + # For all sub-groups we need to give the right people access. We do this as + # follows: + # + # 1. Get all the ancestors for the current namespace + # 2. Get all the members of these namespaces, along with their higher access + # level + # 3. Give these members access to the current namespace + Namespace.unscoped.where('parent_id IS NOT NULL').find_each do |namespace| + rows = [] + existing = namespace.members.pluck(:user_id) + + all_members_for(namespace).each do |member| + next if existing.include?(member[:user_id]) + + rows << { + access_level: member[:access_level], + source_id: namespace.id, + source_type: 'Namespace', + user_id: member[:user_id], + notification_level: 3, # global + type: 'GroupMember', + created_at: Time.current, + updated_at: Time.current + } + end + + bulk_insert_members(rows) + + # This method relies on the parent to determine the proper path. + # Because we reset "parent_id" this method will not return the right path + # when moving namespaces. + full_path_was = namespace.send(:full_path_was) + + namespace.define_singleton_method(:full_path_was) { full_path_was } + + namespace.update!(parent_id: nil, path: new_path_for(namespace)) + end + end + + def down + # There is no way to go back from regular groups to nested groups. + end + + # Generates a new (unique) path for a namespace. + def new_path_for(namespace) + counter = 1 + base = namespace.full_path.tr('/', '-') + new_path = base + + while Namespace.unscoped.where(path: new_path).exists? + new_path = base + "-#{counter}" + counter += 1 + end + + new_path + end + + # Returns an Array containing all the ancestors of the current namespace. + # + # This method is not particularly efficient, but it's probably still faster + # than using the "routes" table. Most importantly of all, it _only_ depends + # on the namespaces table and the "parent_id" column. + def ancestors_for(namespace) + ancestors = [] + current = namespace + + while current&.parent_id + # We're using find_by(id: ...) here to deal with cases where the + # parent_id may point to a missing row. + current = Namespace.unscoped.select([:id, :parent_id]). + find_by(id: current.parent_id) + + ancestors << current.id if current + end + + ancestors + end + + # Returns a relation containing all the members that have access to any of + # the current namespace's parent namespaces. + def all_members_for(namespace) + Member. + unscoped. + select(['user_id', 'MAX(access_level) AS access_level']). + where(source_type: 'Namespace', source_id: ancestors_for(namespace)). + group(:user_id) + end + + def bulk_insert_members(rows) + return if rows.empty? + + keys = rows.first.keys + + tuples = rows.map do |row| + row.map { |(_, value)| connection.quote(value) } + end + + execute <<-EOF.strip_heredoc + INSERT INTO members (#{keys.join(', ')}) + VALUES #{tuples.map { |tuple| "(#{tuple.join(', ')})" }.join(', ')} + EOF + end +end diff --git a/db/migrate/20170504182103_add_index_project_group_links_group_id.rb b/db/migrate/20170504182103_add_index_project_group_links_group_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..62bf641daa6ab4719fe7ea08850f7e03076c0527 --- /dev/null +++ b/db/migrate/20170504182103_add_index_project_group_links_group_id.rb @@ -0,0 +1,19 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddIndexProjectGroupLinksGroupId < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :project_group_links, :group_id + end + + def down + remove_concurrent_index :project_group_links, :group_id + end +end diff --git a/db/post_migrate/20170131214021_reset_users_authorized_projects_populated.rb b/db/post_migrate/20170131214021_reset_users_authorized_projects_populated.rb index b518038e93a9c6c381decaeaf913c65f0535427b..82f8147547ebb465e4568011f9f53e4c2a81084e 100644 --- a/db/post_migrate/20170131214021_reset_users_authorized_projects_populated.rb +++ b/db/post_migrate/20170131214021_reset_users_authorized_projects_populated.rb @@ -1,6 +1,4 @@ -# See http://doc.gitlab.com/ce/development/migration_style_guide.html -# for more information on how to write migrations for GitLab. - +# rubocop:disable Migration/UpdateColumnInBatches class ResetUsersAuthorizedProjectsPopulated < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb b/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb index b61dd7cfc618d528a59b4b2b277cbab581a483cf..b1c9eed114825cfb942fa940e05157fa1977223d 100644 --- a/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb +++ b/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb @@ -1,6 +1,4 @@ -# See http://doc.gitlab.com/ce/development/migration_style_guide.html -# for more information on how to write migrations for GitLab. - +# rubocop:disable Migration/UpdateColumnInBatches class ResetRelativePositionForIssue < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/post_migrate/20170502070007_enable_auto_cancel_pending_pipelines_for_all.rb b/db/post_migrate/20170502070007_enable_auto_cancel_pending_pipelines_for_all.rb index a19b73fc114b6cb94aec7b9b8422a09e0446a0ba..3c13a3d2518b6a43178bc6a7bdfe5e4c565bb23d 100644 --- a/db/post_migrate/20170502070007_enable_auto_cancel_pending_pipelines_for_all.rb +++ b/db/post_migrate/20170502070007_enable_auto_cancel_pending_pipelines_for_all.rb @@ -1,3 +1,4 @@ +# rubocop:disable Migration/UpdateColumnInBatches class EnableAutoCancelPendingPipelinesForAll < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers diff --git a/db/post_migrate/20170503120310_remove_users_authorized_projects_populated.rb b/db/post_migrate/20170503120310_remove_users_authorized_projects_populated.rb new file mode 100644 index 0000000000000000000000000000000000000000..1b44334395f90248cab48e55184534930a55960b --- /dev/null +++ b/db/post_migrate/20170503120310_remove_users_authorized_projects_populated.rb @@ -0,0 +1,15 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveUsersAuthorizedProjectsPopulated < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def change + remove_column :users, :authorized_projects_populated, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index f6b513a572577ee9b0e42a2d70d19e987924b529..4c73f74ef1f9654954ce65c1fa0fb2ed1c7501c7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -928,6 +928,8 @@ ActiveRecord::Schema.define(version: 20170523091700) do t.date "expires_at" end + add_index "project_group_links", ["group_id"], name: "index_project_group_links_on_group_id", using: :btree + create_table "project_import_data", force: :cascade do |t| t.integer "project_id" t.text "data" @@ -1355,7 +1357,6 @@ ActiveRecord::Schema.define(version: 20170523091700) do t.boolean "external", default: false t.string "incoming_email_token" t.string "organization" - t.boolean "authorized_projects_populated" t.boolean "require_two_factor_authentication_from_group", default: false, null: false t.integer "two_factor_grace_period", default: 48, null: false t.boolean "ghost" diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md index 6c6942a7bfea36a2f8307cbc2e016552112d7f45..48929910a9c86a78b2dbc39c1c0e26b4de2396b9 100644 --- a/doc/administration/gitaly/index.md +++ b/doc/administration/gitaly/index.md @@ -2,7 +2,7 @@ [Gitaly](https://gitlab.com/gitlab-org/gitaly) (introduced in GitLab 9.0) is a service that provides high-level RPC access to Git -repositories. As of GitLab 9.1 it is still an optional component with +repositories. As of GitLab 9.3 it is still an optional component with limited scope. GitLab components that access Git repositories (gitlab-rails, @@ -35,7 +35,7 @@ gitlab restart`. ## Configuring GitLab to not use Gitaly -Gitaly is still an optional component in GitLab 9.0. This means you +Gitaly is still an optional component in GitLab 9.3. This means you can choose to not use it. In Omnibus you can make the following change in diff --git a/doc/api/projects.md b/doc/api/projects.md index 6b919f7179246289c36e7c59cef79a887be0962b..345f93a601780b209bf4869c5ceafc9f125d2279 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -38,6 +38,8 @@ Parameters: | `membership` | boolean | no | Limit by projects that the current user is a member of | | `starred` | boolean | no | Limit by projects starred by the current user | | `statistics` | boolean | no | Include project statistics | +| `with_issues_enabled` | boolean | no | Limit by enabled issues feature | +| `with_merge_requests_enabled` | boolean | no | Limit by enabled merge requests feature | ```json [ diff --git a/doc/install/database_mysql.md b/doc/install/database_mysql.md index da2dac23c6a12d65d3e2143ebb87e3ab9875aeb2..9a171d346714d57a5b84f2a66d6e555ee063e54d 100644 --- a/doc/install/database_mysql.md +++ b/doc/install/database_mysql.md @@ -281,5 +281,5 @@ GitLab database to `longtext` columns, which can persist values of up to 4GB Details can be found in the [PostgreSQL][postgres-text-type] and [MySQL][mysql-text-types] manuals. -[postgres-text-type]: http://www.postgresql.org/docs/9.1/static/datatype-character.html +[postgres-text-type]: http://www.postgresql.org/docs/9.2/static/datatype-character.html [mysql-text-types]: http://dev.mysql.com/doc/refman/5.7/en/string-type-overview.html diff --git a/doc/install/installation.md b/doc/install/installation.md index 5bba405f159cb0f83515da2525109cf4bff316df..af21d99d024e2491b976ec4a6caedb9fb63d302b 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -166,7 +166,7 @@ In many distros the versions provided by the official package repositories are out of date, so we'll need to install through the following commands: # install node v7.x - curl --location https://deb.nodesource.com/setup_7.x | bash - + curl --location https://deb.nodesource.com/setup_7.x | sudo bash - sudo apt-get install -y nodejs # install yarn @@ -185,7 +185,8 @@ Create a `git` user for GitLab: We recommend using a PostgreSQL database. For MySQL check the [MySQL setup guide](database_mysql.md). -> **Note**: because we need to make use of extensions you need at least pgsql 9.1. +> **Note**: because we need to make use of extensions and concurrent index removal, +you need at least PostgreSQL 9.2. 1. Install the database packages: @@ -469,10 +470,6 @@ Make GitLab start on boot: ### Install Gitaly -As of GitLab 9.1 Gitaly is an **optional** component. Its -configuration is still changing regularly. It is OK to wait -with setting up Gitaly until you upgrade to GitLab 9.2 or later. - # Fetch Gitaly source with Git and compile with Go sudo -u git -H bundle exec rake "gitlab:gitaly:install[/home/git/gitaly]" RAILS_ENV=production @@ -490,16 +487,6 @@ Next, make sure gitaly configured: cd /home/git/gitaly sudo -u git -H editor config.toml - # Enable Gitaly in the init script - echo 'gitaly_enabled=true' | sudo tee -a /etc/default/gitlab - -Next, edit `/home/git/gitlab/config/gitlab.yml` and make sure `enabled: true` in -the `gitaly:` section is uncommented. - - # <- gitlab.yml indentation starts here - gitaly: - enabled: true - For more information about configuring Gitaly see [doc/administration/gitaly](../administration/gitaly). diff --git a/doc/install/requirements.md b/doc/install/requirements.md index 2e456557d776bc0e824def7f6961a14f9f776e7d..5338ccb9d3a4c5b702e0011917dd42378526bb42 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -138,7 +138,7 @@ installation (e.g. the number of users, projects, etc). ### PostgreSQL Requirements -As of GitLab 9.0, PostgreSQL 9.2 or newer is required, and earlier versions are +As of GitLab 9.3, PostgreSQL 9.2 or newer is required, and earlier versions are not supported. We highly recommend users to use at least PostgreSQL 9.6 as this is the PostgreSQL version used for development and testing. diff --git a/doc/update/9.2-to-9.3.md b/doc/update/9.2-to-9.3.md new file mode 100644 index 0000000000000000000000000000000000000000..26049721fd381e13e84a03f0dd77a7a13ac77052 --- /dev/null +++ b/doc/update/9.2-to-9.3.md @@ -0,0 +1,285 @@ +# From 9.2 to 9.3 + +Make sure you view this update guide from the tag (version) of GitLab you would +like to install. In most cases this should be the highest numbered production +tag (without rc in it). You can select the tag in the version dropdown at the +top left corner of GitLab (below the menu bar). + +If the highest number stable branch is unclear please check the +[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation +guide links by version. + +### 1. Stop server + +```bash +sudo service gitlab stop +``` + +### 2. Backup + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production +``` + +### 3. Update Ruby + +NOTE: GitLab 9.0 and higher only support Ruby 2.3.x and dropped support for Ruby 2.1.x. Be +sure to upgrade your interpreter if necessary. + +You can check which version you are running with `ruby -v`. + +Download and compile Ruby: + +```bash +mkdir /tmp/ruby && cd /tmp/ruby +curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.3.tar.gz +echo '1014ee699071aa2ddd501907d18cbe15399c997d ruby-2.3.3.tar.gz' | shasum -c - && tar xzf ruby-2.3.3.tar.gz +cd ruby-2.3.3 +./configure --disable-install-rdoc +make +sudo make install +``` + +Install Bundler: + +```bash +sudo gem install bundler --no-ri --no-rdoc +``` + +### 4. Update Node + +GitLab now runs [webpack](http://webpack.js.org) to compile frontend assets and +it has a minimum requirement of node v4.3.0. + +You can check which version you are running with `node -v`. If you are running +a version older than `v4.3.0` you will need to update to a newer version. You +can find instructions to install from community maintained packages or compile +from source at the nodejs.org website. + +<https://nodejs.org/en/download/> + + +Since 8.17, GitLab requires the use of yarn `>= v0.17.0` to manage +JavaScript dependencies. + +```bash +curl --location https://yarnpkg.com/install.sh | bash - +``` + +More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install). + +### 5. Get latest code + +```bash +cd /home/git/gitlab + +sudo -u git -H git fetch --all +sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically +``` + +For GitLab Community Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 9-3-stable +``` + +OR + +For GitLab Enterprise Edition: + +```bash +cd /home/git/gitlab + +sudo -u git -H git checkout 9-3-stable-ee +``` + +### 6. Update gitlab-shell + +```bash +cd /home/git/gitlab-shell + +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION) +sudo -u git -H bin/compile +``` + +### 7. Update gitlab-workhorse + +Install and compile gitlab-workhorse. This requires +[Go 1.5](https://golang.org/dl) which should already be on your system from +GitLab 8.1. GitLab-Workhorse uses [GNU Make](https://www.gnu.org/software/make/). +If you are not using Linux you may have to run `gmake` instead of +`make` below. + +```bash +cd /home/git/gitlab-workhorse + +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION) +sudo -u git -H make +``` + +### 8. Update Gitaly + +If you have not yet set up Gitaly then follow [Gitaly section of the installation +guide](../install/installation.md#install-gitaly). + +#### Compile Gitaly + +```shell +cd /home/git/gitaly +sudo -u git -H git fetch --all --tags +sudo -u git -H git checkout v$(</home/git/gitlab/GITALY_SERVER_VERSION) +sudo -u git -H make +``` + +### 9. Update configuration files + +#### New configuration options for `gitlab.yml` + +There might be configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`: + +```sh +cd /home/git/gitlab + +git diff origin/9-2-stable:config/gitlab.yml.example origin/9-3-stable:config/gitlab.yml.example +``` + +#### Nginx configuration + +Ensure you're still up-to-date with the latest NGINX configuration changes: + +```sh +cd /home/git/gitlab + +# For HTTPS configurations +git diff origin/9-2-stable:lib/support/nginx/gitlab-ssl origin/9-3-stable:lib/support/nginx/gitlab-ssl + +# For HTTP configurations +git diff origin/9-2-stable:lib/support/nginx/gitlab origin/9-3-stable:lib/support/nginx/gitlab +``` + +If you are using Strict-Transport-Security in your installation to continue using it you must enable it in your Nginx +configuration as GitLab application no longer handles setting it. + +If you are using Apache instead of NGINX please see the updated [Apache templates]. +Also note that because Apache does not support upstreams behind Unix sockets you +will need to let gitlab-workhorse listen on a TCP port. You can do this +via [/etc/default/gitlab]. + +[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache +[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-3-stable/lib/support/init.d/gitlab.default.example#L38 + +#### SMTP configuration + +If you're installing from source and use SMTP to deliver mail, you will need to add the following line +to config/initializers/smtp_settings.rb: + +```ruby +ActionMailer::Base.delivery_method = :smtp +``` + +See [smtp_settings.rb.sample] as an example. + +[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-2-stable/config/initializers/smtp_settings.rb.sample#L13 + +#### Init script + +There might be new configuration options available for [`gitlab.default.example`][gl-example]. View them with the command below and apply them manually to your current `/etc/default/gitlab`: + +```sh +cd /home/git/gitlab + +git diff origin/9-2-stable:lib/support/init.d/gitlab.default.example origin/9-3-stable:lib/support/init.d/gitlab.default.example +``` + +Ensure you're still up-to-date with the latest init script changes: + +```bash +cd /home/git/gitlab + +sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab +``` + +For Ubuntu 16.04.1 LTS: + +```bash +sudo systemctl daemon-reload +``` + +### 10. Install libs, migrations, etc. + +```bash +cd /home/git/gitlab + +# MySQL installations (note: the line below states '--without postgres') +sudo -u git -H bundle install --without postgres development test --deployment + +# PostgreSQL installations (note: the line below states '--without mysql') +sudo -u git -H bundle install --without mysql development test --deployment + +# Optional: clean up old gems +sudo -u git -H bundle clean + +# Run database migrations +sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production + +# Update node dependencies and recompile assets +sudo -u git -H bundle exec rake yarn:install gitlab:assets:clean gitlab:assets:compile RAILS_ENV=production NODE_ENV=production + +# Clean up cache +sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production +``` + +**MySQL installations**: Run through the `MySQL strings limits` and `Tables and data conversion to utf8mb4` [tasks](../install/database_mysql.md). + +### 11. Start application + +```bash +sudo service gitlab start +sudo service nginx restart +``` + +### 12. Check application status + +Check if GitLab and its environment are configured correctly: + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production +``` + +To make sure you didn't miss anything run a more thorough check: + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production +``` + +If all items are green, then congratulations, the upgrade is complete! + +## Things went south? Revert to previous version (9.2) + +### 1. Revert the code to the previous version + +Follow the [upgrade guide from 9.1 to 9.2](9.1-to-9.2.md), except for the +database migration (the backup is already migrated to the previous version). + +### 2. Restore from the backup + +```bash +cd /home/git/gitlab + +sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production +``` + +If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above. + +[yaml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-3-stable/config/gitlab.yml.example +[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-3-stable/lib/support/init.d/gitlab.default.example diff --git a/doc/user/group/subgroups/index.md b/doc/user/group/subgroups/index.md index d5edf36f6b0afe915971c909f13f5250cf486417..c4921c74a17bcc9eb2c71ce87f406b0996104abc 100644 --- a/doc/user/group/subgroups/index.md +++ b/doc/user/group/subgroups/index.md @@ -13,6 +13,15 @@ up to 20 levels of nested groups, which among other things can help you to: - **Make it easier to manage people and control visibility.** Give people different [permissions][] depending on their group [membership](#membership). +## Database Requirements + +Nested groups are only supported when you use PostgreSQL. Supporting nested +groups on MySQL in an efficient way is not possible due to MySQL's limitations. +See the following links for more information: + +* <https://gitlab.com/gitlab-org/gitlab-ce/issues/30472> +* <https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10885> + ## Overview A group can have many subgroups inside it, and at the same time a group can have diff --git a/doc/user/project/integrations/jira.md b/doc/user/project/integrations/jira.md index f611029afdc24582e4aa3ed6bc2d84401e413ebe..a048260b033ad3d0da7a068234badf3d2f11f199 100644 --- a/doc/user/project/integrations/jira.md +++ b/doc/user/project/integrations/jira.md @@ -97,7 +97,8 @@ in the table below. | Field | Description | | ----- | ----------- | -| `URL` | The base URL to the JIRA project which is being linked to this GitLab project. E.g., `https://jira.example.com`. | +| `Web URL` | The base URL to the JIRA instance web interface which is being linked to this GitLab project. E.g., `https://jira.example.com`. | +| `JIRA API URL` | The base URL to the JIRA instance API. Web URL value will be used if not set. E.g., `https://jira-api.example.com`. | | `Project key` | The short identifier for your JIRA project, all uppercase, e.g., `PROJ`. | | `Username` | The user name created in [configuring JIRA step](#configuring-jira). | | `Password` |The password of the user created in [configuring JIRA step](#configuring-jira). | diff --git a/features/dashboard/starred_projects.feature b/features/dashboard/starred_projects.feature deleted file mode 100644 index 9dfd2fbab9cb2a43e3364f25ccd22b5185259d20..0000000000000000000000000000000000000000 --- a/features/dashboard/starred_projects.feature +++ /dev/null @@ -1,12 +0,0 @@ -@dashboard -Feature: Dashboard Starred Projects - Background: - Given I sign in as a user - And public project "Community" - And I starred project "Community" - And I own project "Shop" - And I visit dashboard starred projects page - - Scenario: I should see projects list - Then I should see project "Community" - And I should not see project "Shop" diff --git a/features/project/merge_requests/accept.feature b/features/project/merge_requests/accept.feature index c45ed9ea68b3d378a1d1a8deafce7b62da5d5a73..2ab1c19f4527b82c96611b6c19f32a3a8d5199fa 100644 --- a/features/project/merge_requests/accept.feature +++ b/features/project/merge_requests/accept.feature @@ -7,6 +7,7 @@ Feature: Project Merge Requests Acceptance @javascript Scenario: Accepting the Merge Request and removing the source branch Given I am on the Merge Request detail page + When I check the "Remove source branch" option And I click on Accept Merge Request Then I should see merge request merged And I should not see the Remove Source Branch button @@ -14,6 +15,7 @@ Feature: Project Merge Requests Acceptance @javascript Scenario: Accepting the Merge Request when URL has an anchor Given I am on the Merge Request detail with note anchor page + When I check the "Remove source branch" option And I click on Accept Merge Request Then I should see merge request merged And I should not see the Remove Source Branch button @@ -21,7 +23,6 @@ Feature: Project Merge Requests Acceptance @javascript Scenario: Accepting the Merge Request without removing the source branch Given I am on the Merge Request detail page - When I click on "Remove source branch" option When I click on Accept Merge Request Then I should see merge request merged And I should see the Remove Source Branch button diff --git a/features/steps/dashboard/dashboard.rb b/features/steps/dashboard/dashboard.rb index bf09d7b7114be86caba6d64515273a92350637e3..71c69a4fdea79cd4b3ce1a4a8390ab0b2a8e8c70 100644 --- a/features/steps/dashboard/dashboard.rb +++ b/features/steps/dashboard/dashboard.rb @@ -22,7 +22,7 @@ class Spinach::Features::Dashboard < Spinach::FeatureSteps end step 'I click "Create merge request" link' do - click_link "Create merge request" + find_link("Create merge request", visible: false).trigger('click') end step 'I see prefilled new Merge Request page' do diff --git a/features/steps/explore/projects.rb b/features/steps/explore/projects.rb index b2194275751013972855181bf285f5c1a2e81626..1a55f40abb903f6a0b77ddfc461464b8d0518b97 100644 --- a/features/steps/explore/projects.rb +++ b/features/steps/explore/projects.rb @@ -49,7 +49,7 @@ class Spinach::Features::ExploreProjects < Spinach::FeatureSteps step 'I should see an http link to the repository' do project = Project.find_by(name: 'Community') - expect(page).to have_field('project_clone', with: project.http_url_to_repo(@user)) + expect(page).to have_field('project_clone', with: project.http_url_to_repo) end step 'I should see an ssh link to the repository' do diff --git a/features/steps/project/merge_requests/acceptance.rb b/features/steps/project/merge_requests/acceptance.rb index 023f9bef8e570fb969d0cd15362c8b0109d42913..870dc862992a9fc5bf8dccac24619bd1309fe385 100644 --- a/features/steps/project/merge_requests/acceptance.rb +++ b/features/steps/project/merge_requests/acceptance.rb @@ -11,10 +11,14 @@ class Spinach::Features::ProjectMergeRequestsAcceptance < Spinach::FeatureSteps visit merge_request_path(@merge_request, anchor: 'note_123') end - step 'I click on "Remove source branch" option' do + step 'I uncheck the "Remove source branch" option' do uncheck('Remove source branch') end + step 'I check the "Remove source branch" option' do + check('Remove source branch') + end + step 'I click on Accept Merge Request' do click_button('Merge') end diff --git a/features/steps/project/services.rb b/features/steps/project/services.rb index 3c0d987e403b81a26db59349d8d969097d30d9fa..66368a159ec9cd81ac6ccc0f9de5100b0795e654 100644 --- a/features/steps/project/services.rb +++ b/features/steps/project/services.rb @@ -178,7 +178,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps end step 'I fill jira settings' do - fill_in 'URL', with: 'http://jira.example' + fill_in 'Web URL', with: 'http://jira.example' + fill_in 'JIRA API URL', with: 'http://jira.example/api' fill_in 'Username', with: 'gitlab' fill_in 'Password', with: 'gitlab' fill_in 'Project Key', with: 'GITLAB' @@ -186,7 +187,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps end step 'I should see jira service settings saved' do - expect(find_field('URL').value).to eq 'http://jira.example' + expect(find_field('Web URL').value).to eq 'http://jira.example' + expect(find_field('JIRA API URL').value).to eq 'http://jira.example/api' expect(find_field('Username').value).to eq 'gitlab' expect(find_field('Project Key').value).to eq 'GITLAB' end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index cf4affe175898e7afa2b34a76ec9808ec608f9e6..8c5e5c917694ebf13bed5406eefa4b10f6772fe3 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -152,7 +152,10 @@ module API expose :web_url expose :request_access_enabled expose :full_name, :full_path - expose :parent_id + + if ::Group.supports_nested_groups? + expose :parent_id + end expose :statistics, if: :statistics do with_options format_with: -> (value) { value.to_i } do diff --git a/lib/api/groups.rb b/lib/api/groups.rb index 3da7d735da88208d526686ebf9f11ad2a108e3c4..ee85b777afff7ef2b5869001fb65ad0469696d57 100644 --- a/lib/api/groups.rb +++ b/lib/api/groups.rb @@ -70,7 +70,11 @@ module API params do requires :name, type: String, desc: 'The name of the group' requires :path, type: String, desc: 'The path of the group' - optional :parent_id, type: Integer, desc: 'The parent group id for creating nested group' + + if ::Group.supports_nested_groups? + optional :parent_id, type: Integer, desc: 'The parent group id for creating nested group' + end + use :optional_params end post do diff --git a/lib/api/projects.rb b/lib/api/projects.rb index ed5004e8d1ae66fa4b37de60bc93ec2e4fb7a8de..d4fe5c023bf344380b2922d197d8f8cdab320c53 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -58,6 +58,8 @@ module API optional :owned, type: Boolean, default: false, desc: 'Limit by owned by authenticated user' optional :starred, type: Boolean, default: false, desc: 'Limit by starred status' optional :membership, type: Boolean, default: false, desc: 'Limit by projects that the current user is a member of' + optional :with_issues_enabled, type: Boolean, default: false, desc: 'Limit by enabled issues feature' + optional :with_merge_requests_enabled, type: Boolean, default: false, desc: 'Limit by enabled merge requests feature' end params :create_params do @@ -69,11 +71,15 @@ module API options = options.reverse_merge( with: Entities::Project, current_user: current_user, - simple: params[:simple] + simple: params[:simple], + with_issues_enabled: params[:with_issues_enabled], + with_merge_requests_enabled: params[:with_merge_requests_enabled] ) projects = filter_projects(projects) projects = projects.with_statistics if options[:statistics] + projects = projects.with_issues_enabled if options[:with_issues_enabled] + projects = projects.with_merge_requests_enabled if options[:with_merge_requests_enabled] options[:with] = Entities::BasicProjectDetails if options[:simple] present paginate(projects), options diff --git a/lib/api/services.rb b/lib/api/services.rb index cb07df9e24972a8bd87cb7189ae6c78fb3dc9292..47bd9940f77181368bffcff147b23f7fdb96f1da 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -304,7 +304,13 @@ module API required: true, name: :url, type: String, - desc: 'The URL to the JIRA project which is being linked to this GitLab project, e.g., https://jira.example.com' + desc: 'The base URL to the JIRA instance web interface which is being linked to this GitLab project. E.g., https://jira.example.com' + }, + { + required: false, + name: :api_url, + type: String, + desc: 'The base URL to the JIRA instance API. Web URL value will be used if not set. E.g., https://jira-api.example.com' }, { required: true, diff --git a/lib/api/v3/entities.rb b/lib/api/v3/entities.rb index 332f233bf5e56671515b430a638252ca6033d3e2..2e1b243c2db7cb7c9f92495a77bb522094674632 100644 --- a/lib/api/v3/entities.rb +++ b/lib/api/v3/entities.rb @@ -137,7 +137,10 @@ module API expose :web_url expose :request_access_enabled expose :full_name, :full_path - expose :parent_id + + if ::Group.supports_nested_groups? + expose :parent_id + end expose :statistics, if: :statistics do with_options format_with: -> (value) { value.to_i } do diff --git a/lib/api/v3/groups.rb b/lib/api/v3/groups.rb index 6187445fc8d9a3028d8513292b24dd447155bf86..2c52d21fa1c1af724bfa933d75b97bbafd574499 100644 --- a/lib/api/v3/groups.rb +++ b/lib/api/v3/groups.rb @@ -74,7 +74,11 @@ module API params do requires :name, type: String, desc: 'The name of the group' requires :path, type: String, desc: 'The path of the group' - optional :parent_id, type: Integer, desc: 'The parent group id for creating nested group' + + if ::Group.supports_nested_groups? + optional :parent_id, type: Integer, desc: 'The parent group id for creating nested group' + end + use :optional_params end post do diff --git a/lib/banzai/reference_parser/base_parser.rb b/lib/banzai/reference_parser/base_parser.rb index c2503fa2adc231bfa895535427decf4c6a0c5fac..d99a3bfa62533a6c422c306208ef68ca8632c26a 100644 --- a/lib/banzai/reference_parser/base_parser.rb +++ b/lib/banzai/reference_parser/base_parser.rb @@ -163,14 +163,15 @@ module Banzai # been queried the object is returned from the cache. def collection_objects_for_ids(collection, ids) if RequestStore.active? + ids = ids.map(&:to_i) cache = collection_cache[collection_cache_key(collection)] - to_query = ids.map(&:to_i) - cache.keys + to_query = ids - cache.keys unless to_query.empty? collection.where(id: to_query).each { |row| cache[row.id] = row } end - cache.values + cache.values_at(*ids) else collection.where(id: ids) end diff --git a/lib/gitlab/dependency_linker/base_linker.rb b/lib/gitlab/dependency_linker/base_linker.rb index 7ba0b3c39b7839be767da5399fea69a94d560b07..7bbd154eb03f8f7a49e4074c40b9372d8b20e875 100644 --- a/lib/gitlab/dependency_linker/base_linker.rb +++ b/lib/gitlab/dependency_linker/base_linker.rb @@ -1,8 +1,8 @@ module Gitlab module DependencyLinker class BaseLinker - URL_REGEX = %r{https?://[^'"]+}.freeze - REPO_REGEX = %r{[^/'"]+/[^/'"]+}.freeze + URL_REGEX = %r{https?://[^'" ]+}.freeze + REPO_REGEX = %r{[^/'" ]+/[^/'" ]+}.freeze class_attribute :file_type @@ -69,7 +69,7 @@ module Gitlab @highlighted_lines ||= highlighted_text.lines end - def regexp_for_value(value, default: /[^'"]+/) + def regexp_for_value(value, default: /[^'" ]+/) case value when Array Regexp.union(value.map { |v| regexp_for_value(v, default: default) }) diff --git a/lib/gitlab/dependency_linker/json_linker.rb b/lib/gitlab/dependency_linker/json_linker.rb index 1b1ca000977c042c2c08f5d1fd507cc0ecba320c..a8ef25233d8a816c781b33fa97ff253242880105 100644 --- a/lib/gitlab/dependency_linker/json_linker.rb +++ b/lib/gitlab/dependency_linker/json_linker.rb @@ -24,8 +24,8 @@ module Gitlab # link_json('specific_package', '1.0.1', link: :key) # # Will link `specific_package` in `"specific_package": "1.0.1"` def link_json(key, value = nil, link: :value, &url_proc) - key = regexp_for_value(key, default: /[^"]+/) - value = regexp_for_value(value, default: /[^"]+/) + key = regexp_for_value(key, default: /[^" ]+/) + value = regexp_for_value(value, default: /[^" ]+/) if link == :value value = /(?<name>#{value})/ diff --git a/lib/gitlab/git/diff_collection.rb b/lib/gitlab/git/diff_collection.rb index bce3aef8bd3f125a16dbce9e903183ac73ee2f38..898a5ae15f2ec2d8661c867045fe440d7e6b7d91 100644 --- a/lib/gitlab/git/diff_collection.rb +++ b/lib/gitlab/git/diff_collection.rb @@ -19,22 +19,19 @@ module Gitlab @line_count = 0 @byte_count = 0 @overflow = false + @empty = true @array = Array.new end def each(&block) - if @populated - # @iterator.each is slower than just iterating the array in place - @array.each(&block) - else - Gitlab::GitalyClient.migrate(:commit_raw_diffs) do - each_patch(&block) - end + Gitlab::GitalyClient.migrate(:commit_raw_diffs) do + each_patch(&block) end end def empty? - !@iterator.any? + any? # Make sure the iterator has been exercised + @empty end def overflow? @@ -60,7 +57,6 @@ module Gitlab collection = each_with_index do |element, i| @array[i] = yield(element) end - @populated = true collection end @@ -72,7 +68,6 @@ module Gitlab return if @populated each { nil } # force a loop through all diffs - @populated = true nil end @@ -81,15 +76,17 @@ module Gitlab end def each_patch - @iterator.each_with_index do |raw, i| - # First yield cached Diff instances from @array - if @array[i] - yield @array[i] - next - end + i = 0 + @array.each do |diff| + yield diff + i += 1 + end + + return if @overflow + return if @iterator.nil? - # We have exhausted @array, time to create new Diff instances or stop. - break if @overflow + @iterator.each do |raw| + @empty = false if !@all_diffs && i >= @max_files @overflow = true @@ -115,7 +112,13 @@ module Gitlab end yield @array[i] = diff + i += 1 end + + @populated = true + + # Allow iterator to be garbage-collected. It cannot be reused anyway. + @iterator = nil end end end diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 21f2e6b6970de2b9b12f7743a59fe2ec8479d7e5..319633656fff63ff8596f85b28a0210d85b2dda4 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -1,3 +1,5 @@ +# rubocop:disable Metrics/AbcSize + module Gitlab module GonHelper def add_gon_variables @@ -13,6 +15,7 @@ module Gitlab gon.sentry_dsn = current_application_settings.clientside_sentry_dsn if current_application_settings.clientside_sentry_enabled gon.gitlab_url = Gitlab.config.gitlab.url gon.revision = Gitlab::REVISION + gon.gitlab_logo = ActionController::Base.helpers.asset_path('gitlab_logo.png') if current_user gon.current_user_id = current_user.id diff --git a/lib/gitlab/group_hierarchy.rb b/lib/gitlab/group_hierarchy.rb new file mode 100644 index 0000000000000000000000000000000000000000..e9d5d52cabb3cea76669c642648480574fe76832 --- /dev/null +++ b/lib/gitlab/group_hierarchy.rb @@ -0,0 +1,104 @@ +module Gitlab + # Retrieving of parent or child groups based on a base ActiveRecord relation. + # + # This class uses recursive CTEs and as a result will only work on PostgreSQL. + class GroupHierarchy + attr_reader :base, :model + + # base - An instance of ActiveRecord::Relation for which to get parent or + # child groups. + def initialize(base) + @base = base + @model = base.model + end + + # Returns a relation that includes the base set of groups and all their + # ancestors (recursively). + def base_and_ancestors + return model.none unless Group.supports_nested_groups? + + base_and_ancestors_cte.apply_to(model.all) + end + + # Returns a relation that includes the base set of groups and all their + # descendants (recursively). + def base_and_descendants + return model.none unless Group.supports_nested_groups? + + base_and_descendants_cte.apply_to(model.all) + end + + # Returns a relation that includes the base groups, their ancestors, and the + # descendants of the base groups. + # + # The resulting query will roughly look like the following: + # + # WITH RECURSIVE ancestors AS ( ... ), + # descendants AS ( ... ) + # SELECT * + # FROM ( + # SELECT * + # FROM ancestors namespaces + # + # UNION + # + # SELECT * + # FROM descendants namespaces + # ) groups; + # + # Using this approach allows us to further add criteria to the relation with + # Rails thinking it's selecting data the usual way. + def all_groups + return base unless Group.supports_nested_groups? + + ancestors = base_and_ancestors_cte + descendants = base_and_descendants_cte + + ancestors_table = ancestors.alias_to(groups_table) + descendants_table = descendants.alias_to(groups_table) + + union = SQL::Union.new([model.unscoped.from(ancestors_table), + model.unscoped.from(descendants_table)]) + + model. + unscoped. + with. + recursive(ancestors.to_arel, descendants.to_arel). + from("(#{union.to_sql}) #{model.table_name}") + end + + private + + def base_and_ancestors_cte + cte = SQL::RecursiveCTE.new(:base_and_ancestors) + + cte << base.except(:order) + + # Recursively get all the ancestors of the base set. + cte << model. + from([groups_table, cte.table]). + where(groups_table[:id].eq(cte.table[:parent_id])). + except(:order) + + cte + end + + def base_and_descendants_cte + cte = SQL::RecursiveCTE.new(:base_and_descendants) + + cte << base.except(:order) + + # Recursively get all the descendants of the base set. + cte << model. + from([groups_table, cte.table]). + where(groups_table[:parent_id].eq(cte.table[:id])). + except(:order) + + cte + end + + def groups_table + model.arel_table + end + end +end diff --git a/lib/gitlab/health_checks/fs_shards_check.rb b/lib/gitlab/health_checks/fs_shards_check.rb index df962d203b7abf58f1a2b875cb7a86873735524c..e78b7f22e0374ed8a43518eb2c0ebae1efa7749b 100644 --- a/lib/gitlab/health_checks/fs_shards_check.rb +++ b/lib/gitlab/health_checks/fs_shards_check.rb @@ -2,6 +2,9 @@ module Gitlab module HealthChecks class FsShardsCheck extend BaseAbstractCheck + RANDOM_STRING = SecureRandom.hex(1000).freeze + COMMAND_TIMEOUT = '1'.freeze + TIMEOUT_EXECUTABLE = 'timeout'.freeze class << self def readiness @@ -41,8 +44,6 @@ module Gitlab private - RANDOM_STRING = SecureRandom.hex(1000).freeze - def operation_metrics(ok_metric, latency_metric, operation, **labels) with_timing operation do |result, elapsed| [ @@ -63,8 +64,8 @@ module Gitlab @storage_paths ||= Gitlab.config.repositories.storages end - def with_timeout(args) - %w{timeout 1}.concat(args) + def exec_with_timeout(cmd_args, *args, &block) + Gitlab::Popen.popen([TIMEOUT_EXECUTABLE, COMMAND_TIMEOUT].concat(cmd_args), *args, &block) end def tmp_file_path(storage_name) @@ -78,7 +79,7 @@ module Gitlab def storage_stat_test(storage_name) stat_path = File.join(path(storage_name), '.') begin - _, status = Gitlab::Popen.popen(with_timeout(%W{ stat #{stat_path} })) + _, status = exec_with_timeout(%W{ stat #{stat_path} }) status == 0 rescue Errno::ENOENT File.exist?(stat_path) && File::Stat.new(stat_path).readable? @@ -86,7 +87,7 @@ module Gitlab end def storage_write_test(tmp_path) - _, status = Gitlab::Popen.popen(with_timeout(%W{ tee #{tmp_path} })) do |stdin| + _, status = exec_with_timeout(%W{ tee #{tmp_path} }) do |stdin| stdin.write(RANDOM_STRING) end status == 0 @@ -96,7 +97,7 @@ module Gitlab end def storage_read_test(tmp_path) - _, status = Gitlab::Popen.popen(with_timeout(%W{ diff #{tmp_path} - })) do |stdin| + _, status = exec_with_timeout(%W{ diff #{tmp_path} - }) do |stdin| stdin.write(RANDOM_STRING) end status == 0 @@ -106,7 +107,7 @@ module Gitlab end def delete_test_file(tmp_path) - _, status = Gitlab::Popen.popen(with_timeout(%W{ rm -f #{tmp_path} })) + _, status = exec_with_timeout(%W{ rm -f #{tmp_path} }) status == 0 rescue Errno::ENOENT File.delete(tmp_path) rescue Errno::ENOENT diff --git a/lib/gitlab/o_auth/provider.rb b/lib/gitlab/o_auth/provider.rb index 9ad7a38d505919e971da85a2bbf58ebbcd1dedbc..ac9d66c836d9e1dcd124cc05afb2b60b818f4c7b 100644 --- a/lib/gitlab/o_auth/provider.rb +++ b/lib/gitlab/o_auth/provider.rb @@ -22,7 +22,11 @@ module Gitlab def self.config_for(name) name = name.to_s if ldap_provider?(name) - Gitlab::LDAP::Config.new(name).options + if Gitlab::LDAP::Config.valid_provider?(name) + Gitlab::LDAP::Config.new(name).options + else + nil + end else Gitlab.config.omniauth.providers.find { |provider| provider.name == name } end diff --git a/lib/gitlab/project_authorizations/with_nested_groups.rb b/lib/gitlab/project_authorizations/with_nested_groups.rb new file mode 100644 index 0000000000000000000000000000000000000000..bb0df1e3dad25af485134213816026fe1e77739c --- /dev/null +++ b/lib/gitlab/project_authorizations/with_nested_groups.rb @@ -0,0 +1,125 @@ +module Gitlab + module ProjectAuthorizations + # Calculating new project authorizations when supporting nested groups. + # + # This class relies on Common Table Expressions to efficiently get all data, + # including data for nested groups. As a result this class can only be used + # on PostgreSQL. + class WithNestedGroups + attr_reader :user + + # user - The User object for which to calculate the authorizations. + def initialize(user) + @user = user + end + + def calculate + cte = recursive_cte + cte_alias = cte.table.alias(Group.table_name) + projects = Project.arel_table + links = ProjectGroupLink.arel_table + + relations = [ + # The project a user has direct access to. + user.projects.select_for_project_authorization, + + # The personal projects of the user. + user.personal_projects.select_as_master_for_project_authorization, + + # Projects that belong directly to any of the groups the user has + # access to. + Namespace. + unscoped. + select([alias_as_column(projects[:id], 'project_id'), + cte_alias[:access_level]]). + from(cte_alias). + joins(:projects), + + # Projects shared with any of the namespaces the user has access to. + Namespace. + unscoped. + select([links[:project_id], + least(cte_alias[:access_level], + links[:group_access], + 'access_level')]). + from(cte_alias). + joins('INNER JOIN project_group_links ON project_group_links.group_id = namespaces.id'). + joins('INNER JOIN projects ON projects.id = project_group_links.project_id'). + joins('INNER JOIN namespaces p_ns ON p_ns.id = projects.namespace_id'). + where('p_ns.share_with_group_lock IS FALSE') + ] + + union = Gitlab::SQL::Union.new(relations) + + ProjectAuthorization. + unscoped. + with. + recursive(cte.to_arel). + select_from_union(union) + end + + private + + # Builds a recursive CTE that gets all the groups the current user has + # access to, including any nested groups. + def recursive_cte + cte = Gitlab::SQL::RecursiveCTE.new(:namespaces_cte) + members = Member.arel_table + namespaces = Namespace.arel_table + + # Namespaces the user is a member of. + cte << user.groups. + select([namespaces[:id], members[:access_level]]). + except(:order) + + # Sub groups of any groups the user is a member of. + cte << Group.select([namespaces[:id], + greatest(members[:access_level], + cte.table[:access_level], 'access_level')]). + joins(join_cte(cte)). + joins(join_members). + except(:order) + + cte + end + + # Builds a LEFT JOIN to join optional memberships onto the CTE. + def join_members + members = Member.arel_table + namespaces = Namespace.arel_table + + cond = members[:source_id]. + eq(namespaces[:id]). + and(members[:source_type].eq('Namespace')). + and(members[:requested_at].eq(nil)). + and(members[:user_id].eq(user.id)) + + Arel::Nodes::OuterJoin.new(members, Arel::Nodes::On.new(cond)) + end + + # Builds an INNER JOIN to join namespaces onto the CTE. + def join_cte(cte) + namespaces = Namespace.arel_table + cond = cte.table[:id].eq(namespaces[:parent_id]) + + Arel::Nodes::InnerJoin.new(cte.table, Arel::Nodes::On.new(cond)) + end + + def greatest(left, right, column_alias) + sql_function('GREATEST', [left, right], column_alias) + end + + def least(left, right, column_alias) + sql_function('LEAST', [left, right], column_alias) + end + + def sql_function(name, args, column_alias) + alias_as_column(Arel::Nodes::NamedFunction.new(name, args), column_alias) + end + + def alias_as_column(value, alias_to) + Arel::Nodes::As.new(value, Arel::Nodes::SqlLiteral.new(alias_to)) + end + end + end +end diff --git a/lib/gitlab/project_authorizations/without_nested_groups.rb b/lib/gitlab/project_authorizations/without_nested_groups.rb new file mode 100644 index 0000000000000000000000000000000000000000..627e8c5fba26f8129418ced650a803972bf7e606 --- /dev/null +++ b/lib/gitlab/project_authorizations/without_nested_groups.rb @@ -0,0 +1,35 @@ +module Gitlab + module ProjectAuthorizations + # Calculating new project authorizations when not supporting nested groups. + class WithoutNestedGroups + attr_reader :user + + # user - The User object for which to calculate the authorizations. + def initialize(user) + @user = user + end + + def calculate + relations = [ + # Projects the user is a direct member of + user.projects.select_for_project_authorization, + + # Personal projects + user.personal_projects.select_as_master_for_project_authorization, + + # Projects of groups the user is a member of + user.groups_projects.select_for_project_authorization, + + # Projects shared with groups the user is a member of + user.groups.joins(:shared_projects).select_for_project_authorization + ] + + union = Gitlab::SQL::Union.new(relations) + + ProjectAuthorization. + unscoped. + select_from_union(union) + end + end + end +end diff --git a/lib/gitlab/sql/recursive_cte.rb b/lib/gitlab/sql/recursive_cte.rb new file mode 100644 index 0000000000000000000000000000000000000000..5b1b03820a328ff59cd7a60b4bfe855efcee5178 --- /dev/null +++ b/lib/gitlab/sql/recursive_cte.rb @@ -0,0 +1,62 @@ +module Gitlab + module SQL + # Class for easily building recursive CTE statements. + # + # Example: + # + # cte = RecursiveCTE.new(:my_cte_name) + # ns = Arel::Table.new(:namespaces) + # + # cte << Namespace. + # where(ns[:parent_id].eq(some_namespace_id)) + # + # cte << Namespace. + # from([ns, cte.table]). + # where(ns[:parent_id].eq(cte.table[:id])) + # + # Namespace.with. + # recursive(cte.to_arel). + # from(cte.alias_to(ns)) + class RecursiveCTE + attr_reader :table + + # name - The name of the CTE as a String or Symbol. + def initialize(name) + @table = Arel::Table.new(name) + @queries = [] + end + + # Adds a query to the body of the CTE. + # + # relation - The relation object to add to the body of the CTE. + def <<(relation) + @queries << relation + end + + # Returns the Arel relation for this CTE. + def to_arel + sql = Arel::Nodes::SqlLiteral.new(Union.new(@queries).to_sql) + + Arel::Nodes::As.new(table, Arel::Nodes::Grouping.new(sql)) + end + + # Returns an "AS" statement that aliases the CTE name as the given table + # name. This allows one to trick ActiveRecord into thinking it's selecting + # from an actual table, when in reality it's selecting from a CTE. + # + # alias_table - The Arel table to use as the alias. + def alias_to(alias_table) + Arel::Nodes::As.new(table, alias_table) + end + + # Applies the CTE to the given relation, returning a new one that will + # query from it. + def apply_to(relation) + relation.except(:where). + with. + recursive(to_arel). + from(alias_to(relation.model.arel_table)) + end + end + end +end diff --git a/lib/gitlab/url_sanitizer.rb b/lib/gitlab/url_sanitizer.rb index 9ce13feb79a73f7aab32219de5fa60ebb8445d3e..c81dc7e30d0d9ef38c5bbd57a277fe609c9bd542 100644 --- a/lib/gitlab/url_sanitizer.rb +++ b/lib/gitlab/url_sanitizer.rb @@ -18,12 +18,6 @@ module Gitlab false end - def self.http_credentials_for_user(user) - return {} unless user.respond_to?(:username) - - { user: user.username } - end - def initialize(url, credentials: nil) @url = Addressable::URI.parse(url.strip) @credentials = credentials diff --git a/lib/support/init.d/gitlab b/lib/support/init.d/gitlab index 6e351365de0af3905e4cc1d86d144a939bebe765..c5f93336346684fdff78dd6f602dd9b2ab87985b 100755 --- a/lib/support/init.d/gitlab +++ b/lib/support/init.d/gitlab @@ -48,7 +48,7 @@ gitlab_pages_pid_path="$pid_path/gitlab-pages.pid" gitlab_pages_options="-pages-domain example.com -pages-root $app_root/shared/pages -listen-proxy 127.0.0.1:8090" gitlab_pages_log="$app_root/log/gitlab-pages.log" shell_path="/bin/bash" -gitaly_enabled=false +gitaly_enabled=true gitaly_dir=$(cd $app_root/../gitaly 2> /dev/null && pwd) gitaly_pid_path="$pid_path/gitaly.pid" gitaly_log="$app_root/log/gitaly.log" diff --git a/lib/support/init.d/gitlab.default.example b/lib/support/init.d/gitlab.default.example index 9472c3c992f091d9f673282d1d112c6a932df9c7..295c79fccfc9fa0899217cc6f6a11007118c2753 100644 --- a/lib/support/init.d/gitlab.default.example +++ b/lib/support/init.d/gitlab.default.example @@ -86,5 +86,7 @@ mail_room_pid_path="$pid_path/mail_room.pid" shell_path="/bin/bash" # This variable controls whether the init script starts/stops Gitaly -gitaly_enabled=false +gitaly_enabled=true +gitaly_dir=$(cd $app_root/../gitaly 2> /dev/null && pwd) +gitaly_pid_path="$pid_path/gitaly.pid" gitaly_log="$app_root/log/gitaly.log" diff --git a/qa/Dockerfile b/qa/Dockerfile index 72c82503542ad951325a141e713cebf9dfaddee7..9e2a74ef991ec3844011d7188bedc2a5d02daddc 100644 --- a/qa/Dockerfile +++ b/qa/Dockerfile @@ -1,10 +1,25 @@ FROM ruby:2.3 LABEL maintainer "Grzegorz Bizon <grzegorz@gitlab.com>" -RUN sed -i "s/httpredir.debian.org/ftp.us.debian.org/" /etc/apt/sources.list && \ - apt-get update && apt-get install -y --force-yes \ - libqt5webkit5-dev qt5-qmake qt5-default build-essential xvfb git && \ - apt-get clean +## +# Update APT sources and install some dependencies +# +RUN sed -i "s/httpredir.debian.org/ftp.us.debian.org/" /etc/apt/sources.list +RUN apt-get update && apt-get install -y wget git unzip xvfb + +## +# At this point Google Chrome Beta is 59 - first version with headless support +# +RUN wget -q https://dl.google.com/linux/direct/google-chrome-beta_current_amd64.deb +RUN dpkg -i google-chrome-beta_current_amd64.deb; apt-get -fy install + +## +# Install chromedriver to make it work with Selenium +# +RUN wget -q https://chromedriver.storage.googleapis.com/2.29/chromedriver_linux64.zip +RUN unzip chromedriver_linux64.zip -d /usr/local/bin + +RUN apt-get clean WORKDIR /home/qa diff --git a/qa/Gemfile b/qa/Gemfile index 6bfe25ba4371b6372bb7743b47f7269fdc923c80..5d089a459346357e978500c46929713c073c5cad 100644 --- a/qa/Gemfile +++ b/qa/Gemfile @@ -2,6 +2,6 @@ source 'https://rubygems.org' gem 'capybara', '~> 2.12.1' gem 'capybara-screenshot', '~> 1.0.14' -gem 'capybara-webkit', '~> 1.12.0' gem 'rake', '~> 12.0.0' gem 'rspec', '~> 3.5' +gem 'selenium-webdriver', '~> 2.53' diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock index 6de2abff1985fa60949cd279bcc08f5d6488f7b6..4dd71aa50106972e5013b2355f2ecfea7ecfe13b 100644 --- a/qa/Gemfile.lock +++ b/qa/Gemfile.lock @@ -16,7 +16,10 @@ GEM capybara-webkit (1.12.0) capybara (>= 2.3.0, < 2.13.0) json + childprocess (0.7.0) + ffi (~> 1.0, >= 1.0.11) diff-lcs (1.3) + ffi (1.9.18) json (2.0.3) launchy (2.4.3) addressable (~> 2.3) @@ -44,6 +47,12 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.5.0) rspec-support (3.5.0) + rubyzip (1.2.1) + selenium-webdriver (2.53.4) + childprocess (~> 0.5) + rubyzip (~> 1.0) + websocket (~> 1.0) + websocket (1.2.4) xpath (2.0.0) nokogiri (~> 1.3) @@ -56,6 +65,7 @@ DEPENDENCIES capybara-webkit (~> 1.12.0) rake (~> 12.0.0) rspec (~> 3.5) + selenium-webdriver (~> 2.53) BUNDLED WITH 1.14.6 diff --git a/qa/qa/specs/config.rb b/qa/qa/specs/config.rb index d72187fcd34ff750f417e51d752a0949207a1e88..78a93828d36b17c706befdc0f6ab90740eb6976d 100644 --- a/qa/qa/specs/config.rb +++ b/qa/qa/specs/config.rb @@ -1,7 +1,7 @@ require 'rspec/core' require 'capybara/rspec' -require 'capybara-webkit' require 'capybara-screenshot/rspec' +require 'selenium-webdriver' # rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/LineLength @@ -20,7 +20,6 @@ module QA configure_rspec! configure_capybara! - configure_webkit! end def configure_rspec! @@ -43,9 +42,9 @@ module QA config.order = :random Kernel.srand config.seed - config.before(:all) do - page.current_window.resize_to(1200, 1800) - end + # config.before(:all) do + # page.current_window.resize_to(1200, 1800) + # end config.formatter = :documentation config.color = true @@ -53,26 +52,28 @@ module QA end def configure_capybara! + Capybara.register_driver :chrome do |app| + capabilities = Selenium::WebDriver::Remote::Capabilities.chrome( + 'chromeOptions' => { + 'binary' => '/opt/google/chrome-beta/google-chrome-beta', + 'args' => %w[headless no-sandbox disable-gpu] + } + ) + + Capybara::Selenium::Driver + .new(app, browser: :chrome, desired_capabilities: capabilities) + end + Capybara.configure do |config| config.app_host = @address - config.default_driver = :webkit - config.javascript_driver = :webkit + config.default_driver = :chrome + config.javascript_driver = :chrome config.default_max_wait_time = 4 # https://github.com/mattheworiordan/capybara-screenshot/issues/164 config.save_path = 'tmp' end end - - def configure_webkit! - Capybara::Webkit.configure do |config| - config.allow_url(@address) - config.block_unknown_urls - end - rescue RuntimeError # rubocop:disable Lint/HandleExceptions - # TODO, Webkit is already configured, this make this - # configuration step idempotent, should be improved. - end end end end diff --git a/qa/spec/spec_helper.rb b/qa/spec/spec_helper.rb index c07a32346734b883388acd66fd0aff64a301d5a2..64d06ef65580fe31e139787931e7d8155b254502 100644 --- a/qa/spec/spec_helper.rb +++ b/qa/spec/spec_helper.rb @@ -12,7 +12,6 @@ RSpec.configure do |config| config.shared_context_metadata_behavior = :apply_to_host_groups config.disable_monkey_patching! config.expose_dsl_globally = true - config.warnings = true config.profile_examples = 10 config.order = :random Kernel.srand config.seed diff --git a/rubocop/cop/migration/update_column_in_batches.rb b/rubocop/cop/migration/update_column_in_batches.rb new file mode 100644 index 0000000000000000000000000000000000000000..3f886cbfea38c15cc4d11d06d872b93170e50ce4 --- /dev/null +++ b/rubocop/cop/migration/update_column_in_batches.rb @@ -0,0 +1,43 @@ +require_relative '../../migration_helpers' + +module RuboCop + module Cop + module Migration + # Cop that checks if a spec file exists for any migration using + # `update_column_in_batches`. + class UpdateColumnInBatches < RuboCop::Cop::Cop + include MigrationHelpers + + MSG = 'Migration running `update_column_in_batches` must have a spec file at' \ + ' `%s`.'.freeze + + def on_send(node) + return unless in_migration?(node) + return unless node.children[1] == :update_column_in_batches + + spec_path = spec_filename(node) + + unless File.exist?(File.expand_path(spec_path, rails_root)) + add_offense(node, :expression, format(MSG, spec_path)) + end + end + + private + + def spec_filename(node) + source_name = node.location.expression.source_buffer.name + path = Pathname.new(source_name).relative_path_from(rails_root) + dirname = File.dirname(path) + .sub(%r{\Adb/(migrate|post_migrate)}, 'spec/migrations') + filename = File.basename(source_name, '.rb').sub(%r{\A\d+_}, '') + + File.join(dirname, "#{filename}_spec.rb") + end + + def rails_root + Pathname.new(File.expand_path('../../..', __dir__)) + end + end + end + end +end diff --git a/rubocop/migration_helpers.rb b/rubocop/migration_helpers.rb index 3160a784a042299eb5cc450ca09cd859eef96a36..c3473771178ca465594993594933c931bde1fca9 100644 --- a/rubocop/migration_helpers.rb +++ b/rubocop/migration_helpers.rb @@ -3,8 +3,9 @@ module RuboCop module MigrationHelpers # Returns true if the given node originated from the db/migrate directory. def in_migration?(node) - File.dirname(node.location.expression.source_buffer.name). - end_with?('db/migrate') + dirname = File.dirname(node.location.expression.source_buffer.name) + + dirname.end_with?('db/migrate', 'db/post_migrate') end end end diff --git a/rubocop/rubocop.rb b/rubocop/rubocop.rb index 4ff204f939e56d236e519ebcc02fa569af8b34a1..b65efbc41f4de71a725201b9fd5993b0a4cf0cc4 100644 --- a/rubocop/rubocop.rb +++ b/rubocop/rubocop.rb @@ -8,3 +8,4 @@ require_relative 'cop/migration/add_index' require_relative 'cop/migration/remove_concurrent_index' require_relative 'cop/migration/remove_index' require_relative 'cop/migration/reversible_add_column_with_default' +require_relative 'cop/migration/update_column_in_batches' diff --git a/scripts/trigger-build b/scripts/trigger-build index 565bc314ef1266b3e9501a84bbf64ba0d0e41e9b..e4603533872d19694cc3a0c547ce75b99f617fc2 100755 --- a/scripts/trigger-build +++ b/scripts/trigger-build @@ -8,7 +8,8 @@ params = { "ref" => ENV["OMNIBUS_BRANCH"] || "master", "token" => ENV["BUILD_TRIGGER_TOKEN"], "variables[GITLAB_VERSION]" => ENV["CI_COMMIT_SHA"], - "variables[ALTERNATIVE_SOURCES]" => true + "variables[ALTERNATIVE_SOURCES]" => true, + "variables[ee]" => ENV["EE_PACKAGE"] } Dir.glob("*_VERSION").each do |version_file| diff --git a/spec/controllers/autocomplete_controller_spec.rb b/spec/controllers/autocomplete_controller_spec.rb index 7d2f6dd9d0a5f0eadb682441f1ad87afb1247ab7..2c9d1ffc9c29108d89963600d4c15a6fea736bf8 100644 --- a/spec/controllers/autocomplete_controller_spec.rb +++ b/spec/controllers/autocomplete_controller_spec.rb @@ -22,7 +22,7 @@ describe AutocompleteController do let(:body) { JSON.parse(response.body) } it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq 1 } + it { expect(body.size).to eq 2 } it { expect(body.map { |u| u["username"] }).to include(user.username) } end @@ -80,8 +80,8 @@ describe AutocompleteController do end it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq 2 } - it { expect(body.map { |u| u['username'] }).to match_array([user.username, non_member.username]) } + it { expect(body.size).to eq 3 } + it { expect(body.map { |u| u['username'] }).to include(user.username, non_member.username) } end end @@ -97,6 +97,20 @@ describe AutocompleteController do it { expect(body.size).to eq User.count } end + context 'limited users per page' do + let(:per_page) { 2 } + + before do + sign_in(user) + get(:users, per_page: per_page) + end + + let(:body) { JSON.parse(response.body) } + + it { expect(body).to be_kind_of(Array) } + it { expect(body.size).to eq per_page } + end + context 'unauthenticated user' do let(:public_project) { create(:project, :public) } let(:body) { JSON.parse(response.body) } @@ -108,7 +122,7 @@ describe AutocompleteController do end it { expect(body).to be_kind_of(Array) } - it { expect(body.size).to eq 1 } + it { expect(body.size).to eq 2 } end describe 'GET #users with project' do diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 4626f1ebc29718af6bcdfeeb12f69133a51dc114..b0b24b1de1bcdd67ed1429d342c258c6639457c9 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -26,7 +26,7 @@ describe GroupsController do end end - describe 'GET #subgroups' do + describe 'GET #subgroups', :nested_groups do let!(:public_subgroup) { create(:group, :public, parent: group) } let!(:private_subgroup) { create(:group, :private, parent: group) } diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 587a5820c6f81aff5d05478a6c94028d9025333c..08024a2148b4dce8380d0922897a6c4af3e9bfef 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Projects::MergeRequestsController do let(:project) { create(:project) } - let(:user) { create(:user) } + let(:user) { project.owner } let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) } let(:merge_request_with_conflicts) do create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project) do |mr| @@ -12,7 +12,6 @@ describe Projects::MergeRequestsController do before do sign_in(user) - project.team << [user, :master] end describe 'GET new' do @@ -304,6 +303,8 @@ describe Projects::MergeRequestsController do end context 'when user cannot access' do + let(:user) { create(:user) } + before do project.add_reporter(user) xhr :post, :merge, base_params @@ -459,6 +460,8 @@ describe Projects::MergeRequestsController do end describe "DELETE destroy" do + let(:user) { create(:user) } + it "denies access to users unless they're admin or project owner" do delete :destroy, namespace_id: project.namespace, project_id: project, id: merge_request.iid diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 7a76f5f8afce0fc88a0ba63a96535ea8dd0ef3e6..e8a9b688319717e49053faf4af269633cefc8d21 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -109,6 +109,18 @@ FactoryGirl.define do merge_requests_access_level: merge_requests_access_level, repository_access_level: evaluator.repository_access_level ) + + # Normally the class Projects::CreateService is used for creating + # projects, and this class takes care of making sure the owner and current + # user have access to the project. Our specs don't use said service class, + # thus we must manually refresh things here. + owner = project.owner + + if owner && owner.is_a?(User) && !project.pending_delete + project.members.create!(user: owner, access_level: Gitlab::Access::MASTER) + end + + project.group&.refresh_members_authorized_projects end end diff --git a/spec/features/admin/admin_disables_git_access_protocol_spec.rb b/spec/features/admin/admin_disables_git_access_protocol_spec.rb index 273cacd82cd2e1957f1a98e44808e07aaff8aad0..e8e080ce3e26ba52fc3c18fd170249e4320d89ae 100644 --- a/spec/features/admin/admin_disables_git_access_protocol_spec.rb +++ b/spec/features/admin/admin_disables_git_access_protocol_spec.rb @@ -32,7 +32,7 @@ feature 'Admin disables Git access protocol', feature: true do scenario 'shows only HTTP url' do visit_project - expect(page).to have_content("git clone #{project.http_url_to_repo(admin)}") + expect(page).to have_content("git clone #{project.http_url_to_repo}") expect(page).not_to have_selector('#clone-dropdown') end end diff --git a/spec/features/admin/admin_system_info_spec.rb b/spec/features/admin/admin_system_info_spec.rb index 1df972843e2e9c3e678b943b48ec1f006d2bc98c..1548234788668c6df091677f0329c52a0be32b0e 100644 --- a/spec/features/admin/admin_system_info_spec.rb +++ b/spec/features/admin/admin_system_info_spec.rb @@ -20,6 +20,7 @@ describe 'Admin System Info' do expect(page).to have_content 'CPU 2 cores' expect(page).to have_content 'Memory 4 GB / 16 GB' expect(page).to have_content 'Disks' + expect(page).to have_content 'Uptime' end end @@ -34,6 +35,7 @@ describe 'Admin System Info' do expect(page).to have_content 'CPU Unable to collect CPU info' expect(page).to have_content 'Memory 4 GB / 16 GB' expect(page).to have_content 'Disks' + expect(page).to have_content 'Uptime' end end @@ -48,6 +50,7 @@ describe 'Admin System Info' do expect(page).to have_content 'CPU 2 cores' expect(page).to have_content 'Memory Unable to collect memory info' expect(page).to have_content 'Disks' + expect(page).to have_content 'Uptime' end end end diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb index 2346a9ec2edbdc1715ba3c909b8f5526dfe47780..2cea6b1563e302b46cd88c91789f4084386285f8 100644 --- a/spec/features/dashboard/issues_spec.rb +++ b/spec/features/dashboard/issues_spec.rb @@ -2,66 +2,75 @@ require 'spec_helper' RSpec.describe 'Dashboard Issues', feature: true do let(:current_user) { create :user } - let(:public_project) { create(:empty_project, :public) } - let(:project) do - create(:empty_project) do |project| - project.team << [current_user, :master] - end - end - + let!(:public_project) { create(:empty_project, :public) } + let(:project) { create(:empty_project) } + let(:project_with_issues_disabled) { create(:empty_project, :issues_disabled) } let!(:authored_issue) { create :issue, author: current_user, project: project } let!(:authored_issue_on_public_project) { create :issue, author: current_user, project: public_project } let!(:assigned_issue) { create :issue, assignees: [current_user], project: project } let!(:other_issue) { create :issue, project: project } before do + [project, project_with_issues_disabled].each { |project| project.team << [current_user, :master] } login_as(current_user) - visit issues_dashboard_path(assignee_id: current_user.id) end - it 'shows issues assigned to current user' do - expect(page).to have_content(assigned_issue.title) - expect(page).not_to have_content(authored_issue.title) - expect(page).not_to have_content(other_issue.title) - end + describe 'issues' do + it 'shows issues assigned to current user' do + expect(page).to have_content(assigned_issue.title) + expect(page).not_to have_content(authored_issue.title) + expect(page).not_to have_content(other_issue.title) + end - it 'shows checkmark when unassigned is selected for assignee', js: true do - find('.js-assignee-search').click - find('li', text: 'Unassigned').click - find('.js-assignee-search').click + it 'shows checkmark when unassigned is selected for assignee', js: true do + find('.js-assignee-search').click + find('li', text: 'Unassigned').click + find('.js-assignee-search').click - expect(find('li[data-user-id="0"] a.is-active')).to be_visible - end + expect(find('li[data-user-id="0"] a.is-active')).to be_visible + end + + it 'shows issues when current user is author', js: true do + find('#assignee_id', visible: false).set('') + find('.js-author-search', match: :first).click - it 'shows issues when current user is author', js: true do - find('#assignee_id', visible: false).set('') - find('.js-author-search', match: :first).click + expect(find('li[data-user-id="null"] a.is-active')).to be_visible - expect(find('li[data-user-id="null"] a.is-active')).to be_visible + find('.dropdown-menu-author li a', match: :first, text: current_user.to_reference).click + find('.js-author-search', match: :first).click - find('.dropdown-menu-author li a', match: :first, text: current_user.to_reference).click - find('.js-author-search', match: :first).click + page.within '.dropdown-menu-user' do + expect(find('.dropdown-menu-author li a.is-active', match: :first, text: current_user.to_reference)).to be_visible + end - page.within '.dropdown-menu-user' do - expect(find('.dropdown-menu-author li a.is-active', match: :first, text: current_user.to_reference)).to be_visible + expect(page).to have_content(authored_issue.title) + expect(page).to have_content(authored_issue_on_public_project.title) + expect(page).not_to have_content(assigned_issue.title) + expect(page).not_to have_content(other_issue.title) end - expect(page).to have_content(authored_issue.title) - expect(page).to have_content(authored_issue_on_public_project.title) - expect(page).not_to have_content(assigned_issue.title) - expect(page).not_to have_content(other_issue.title) - end + it 'shows all issues' do + click_link('Reset filters') - it 'shows all issues' do - click_link('Reset filters') + expect(page).to have_content(authored_issue.title) + expect(page).to have_content(authored_issue_on_public_project.title) + expect(page).to have_content(assigned_issue.title) + expect(page).to have_content(other_issue.title) + end - expect(page).to have_content(authored_issue.title) - expect(page).to have_content(authored_issue_on_public_project.title) - expect(page).to have_content(assigned_issue.title) - expect(page).to have_content(other_issue.title) + it_behaves_like "it has an RSS button with current_user's RSS token" + it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" end - it_behaves_like "it has an RSS button with current_user's RSS token" - it_behaves_like "an autodiscoverable RSS feed with current_user's RSS token" + describe 'new issue dropdown' do + it 'shows projects only with issues feature enabled', js: true do + find('.new-project-item-select-button').trigger('click') + + page.within('.select2-results') do + expect(page).to have_content(project.name_with_namespace) + expect(page).not_to have_content(project_with_issues_disabled.name_with_namespace) + end + end + end end diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb index 508ca38d7e5cea8125fc8f6351a051c871b04c9e..9cebe52c4441b0690b0383bbc2fcc33415f50176 100644 --- a/spec/features/dashboard/merge_requests_spec.rb +++ b/spec/features/dashboard/merge_requests_spec.rb @@ -2,16 +2,28 @@ require 'spec_helper' describe 'Dashboard Merge Requests' do let(:current_user) { create :user } - let(:project) do - create(:empty_project) do |project| - project.add_master(current_user) - end - end + let(:project) { create(:empty_project) } + let(:project_with_merge_requests_disabled) { create(:empty_project, :merge_requests_disabled) } before do + [project, project_with_merge_requests_disabled].each { |project| project.team << [current_user, :master] } + login_as(current_user) end + describe 'new merge request dropdown' do + before { visit merge_requests_dashboard_path } + + it 'shows projects only with merge requests feature enabled', js: true do + find('.new-project-item-select-button').trigger('click') + + page.within('.select2-results') do + expect(page).to have_content(project.name_with_namespace) + expect(page).not_to have_content(project_with_merge_requests_disabled.name_with_namespace) + end + end + end + it 'should show an empty state' do visit merge_requests_dashboard_path(assignee_id: current_user.id) diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb index 01351548a99e67a8a9594a94fb35b319594cd333..fa3435ab719156fa403ff9af62ab7d7e30b52c75 100644 --- a/spec/features/dashboard/projects_spec.rb +++ b/spec/features/dashboard/projects_spec.rb @@ -3,10 +3,11 @@ require 'spec_helper' RSpec.describe 'Dashboard Projects', feature: true do let(:user) { create(:user) } let(:project) { create(:project, name: "awesome stuff") } + let(:project2) { create(:project, :public, name: 'Community project') } before do project.team << [user, :developer] - login_as user + login_as(user) end it 'shows the project the user in a member of in the list' do @@ -14,6 +15,17 @@ RSpec.describe 'Dashboard Projects', feature: true do expect(page).to have_content('awesome stuff') end + context 'when on Starred projects tab' do + it 'shows only starred projects' do + user.toggle_star(project2) + + visit(starred_dashboard_projects_path) + + expect(page).not_to have_content(project.name) + expect(page).to have_content(project2.name) + end + end + describe "with a pipeline", redis: true do let!(:pipeline) { create(:ci_pipeline, project: project, sha: project.commit.sha) } diff --git a/spec/features/groups/group_name_toggle_spec.rb b/spec/features/groups/group_name_toggle_spec.rb index 8a1d415c4f1fdb2718fa7658edf97f10a9e3e0f7..dfc3c84f29af23c0d3489f4e25deed2069a99d49 100644 --- a/spec/features/groups/group_name_toggle_spec.rb +++ b/spec/features/groups/group_name_toggle_spec.rb @@ -22,7 +22,7 @@ feature 'Group name toggle', feature: true, js: true do expect(page).not_to have_css('.group-name-toggle') end - it 'is present if the title is longer than the container' do + 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") @@ -35,7 +35,7 @@ feature 'Group name toggle', feature: true, js: true do expect(title_width).to be > container_width end - it 'should show the full group namespace when toggled' do + 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) diff --git a/spec/features/groups/members/list_spec.rb b/spec/features/groups/members/list_spec.rb index 543879bd21da334075fd67738b6809ea6cdf0096..f654fa16a0670bc861c913eed996379146609c54 100644 --- a/spec/features/groups/members/list_spec.rb +++ b/spec/features/groups/members/list_spec.rb @@ -12,7 +12,7 @@ feature 'Groups members list', feature: true do login_as(user1) end - scenario 'show members from current group and parent' do + scenario 'show members from current group and parent', :nested_groups do group.add_developer(user1) nested_group.add_developer(user2) @@ -22,7 +22,7 @@ feature 'Groups members list', feature: true do expect(second_row.text).to include(user2.name) end - scenario 'show user once if member of both current group and parent' do + scenario 'show user once if member of both current group and parent', :nested_groups do group.add_developer(user1) nested_group.add_developer(user1) diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index 3d32c47bf09afe1381dd3a75bdad12e6be4524d6..24ea7aba0ccecedfebc36fad348b2c2605111e12 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -83,7 +83,7 @@ feature 'Group', feature: true do end end - describe 'create a nested group', js: true do + describe 'create a nested group', :nested_groups, js: true do let(:group) { create(:group, path: 'foo') } context 'as admin' do @@ -196,7 +196,7 @@ feature 'Group', feature: true do end end - describe 'group page with nested groups', js: true do + describe 'group page with nested groups', :nested_groups, js: true do let!(:group) { create(:group) } let!(:nested_group) { create(:group, parent: group) } let!(:path) { group_path(group) } diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb index 0b573d7cef45b9e04b341453753782f2b52ce366..4d38df05928224b0b4ce6f4d8c79c00d4004e9f4 100644 --- a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb @@ -58,7 +58,7 @@ describe 'Dropdown assignee', :feature, :js do it 'should load all the assignees when opened' do filtered_search.set('assignee:') - expect(dropdown_assignee_size).to eq(3) + expect(dropdown_assignee_size).to eq(4) end it 'shows current user at top of dropdown' do diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb index b29177bed0669384ae4f09ab8c3e95d9d0efe6f1..358b244fb5b16015f5f54f92b425cbbc0b5014b8 100644 --- a/spec/features/issues/filtered_search/dropdown_author_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_author_spec.rb @@ -65,7 +65,7 @@ describe 'Dropdown author', js: true, feature: true do it 'should load all the authors when opened' do send_keys_to_filtered_search('author:') - expect(dropdown_author_size).to eq(3) + expect(dropdown_author_size).to eq(4) end it 'shows current user at top of dropdown' do diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index 65d854d0896bcfdd53c1c5ed92ec1b4044c6569d..8949dbcb663bf6eaa07a51f3a31b742c723f0fd7 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -3,6 +3,7 @@ require 'rails_helper' describe 'New/edit issue', :feature, :js do include GitlabRoutingHelper include ActionView::Helpers::JavaScriptHelper + include FormHelper let!(:project) { create(:project) } let!(:user) { create(:user)} @@ -23,6 +24,65 @@ describe 'New/edit issue', :feature, :js do visit new_namespace_project_issue_path(project.namespace, project) end + describe 'shorten users API pagination limit' do + before do + allow_any_instance_of(FormHelper).to receive(:issue_dropdown_options).and_wrap_original do |original, *args| + has_multiple_assignees = *args[1] + + options = { + toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data', + title: 'Select assignee', + filter: true, + dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee', + placeholder: 'Search users', + data: { + per_page: 1, + null_user: true, + current_user: true, + project_id: project.try(:id), + field_name: "issue[assignee_ids][]", + default_label: 'Assignee', + 'max-select': 1, + 'dropdown-header': 'Assignee', + multi_select: true, + 'input-meta': 'name', + 'always-show-selectbox': true + } + } + + if has_multiple_assignees + options[:title] = 'Select assignee(s)' + options[:data][:'dropdown-header'] = 'Assignee(s)' + options[:data].delete(:'max-select') + end + + options + end + + visit new_namespace_project_issue_path(project.namespace, project) + + click_button 'Unassigned' + + wait_for_requests + end + + it 'should display selected users even if they are not part of the original API call' do + find('.dropdown-input-field').native.send_keys user2.name + + page.within '.dropdown-menu-user' do + expect(page).to have_content user2.name + click_link user2.name + end + + find('.js-dropdown-input-clear').click + + page.within '.dropdown-menu-user' do + expect(page).to have_content user.name + expect(find('.dropdown-menu-user a.is-active').first(:xpath, '..')['data-user-id']).to eq(user2.id.to_s) + end + end + end + describe 'single assignee' do before do click_button 'Unassigned' @@ -219,6 +279,37 @@ describe 'New/edit issue', :feature, :js do end end + describe 'sub-group project' do + let(:group) { create(:group) } + let(:nested_group_1) { create(:group, parent: group) } + let(:sub_group_project) { create(:empty_project, group: nested_group_1) } + + before do + sub_group_project.add_master(user) + + visit new_namespace_project_issue_path(sub_group_project.namespace, sub_group_project) + end + + it 'creates new label from dropdown' do + click_button 'Labels' + + click_link 'Create new label' + + page.within '.dropdown-new-label' do + fill_in 'new_label_name', with: 'test label' + first('.suggest-colors-dropdown a').click + + click_button 'Create' + + wait_for_requests + end + + page.within '.dropdown-menu-labels' do + expect(page).to have_link 'test label' + end + end + end + def before_for_selector(selector) js = <<-JS.strip_heredoc (function(selector) { diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index 99ad8013023ac48fd454d5c22e8355b17dba2f66..96c24750250a7036a2709e8af80eef20c6ece7fe 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -57,6 +57,23 @@ feature 'Issue Sidebar', feature: true do expect(page.find('.dropdown-menu-user-link.is-active')).to have_content(user.name) end end + + it 'keeps your filtered term after filtering and dismissing the dropdown' do + find('.dropdown-input-field').native.send_keys user2.name + + wait_for_requests + + page.within '.dropdown-menu-user' do + expect(page).not_to have_content 'Unassigned' + click_link user2.name + end + + find('.js-right-sidebar').click + find('.block.assignee .edit-link').click + + expect(page.all('.dropdown-menu-user li').length).to eq(1) + expect(find('.dropdown-input-field').value).to eq(user2.name) + end end context 'as a allowed user' do diff --git a/spec/features/merge_requests/edit_mr_spec.rb b/spec/features/merge_requests/edit_mr_spec.rb index ec87a99b3ab0c4aeb2b4a036a0220be5f0f88168..c77a5c68bc68d096b1186663303aecf80f856a7c 100644 --- a/spec/features/merge_requests/edit_mr_spec.rb +++ b/spec/features/merge_requests/edit_mr_spec.rb @@ -29,6 +29,19 @@ feature 'Edit Merge Request', feature: true do expect(page).to have_content 'Someone edited the merge request the same time you did' end + it 'allows to unselect "Remove source branch"', js: true do + merge_request.update(merge_params: { 'force_remove_source_branch' => '1' }) + expect(merge_request.merge_params['force_remove_source_branch']).to be_truthy + + visit edit_namespace_project_merge_request_path(project.namespace, project, merge_request) + uncheck 'Remove source branch when merge request is accepted' + + click_button 'Save changes' + + expect(page).to have_unchecked_field 'remove-source-branch-input' + expect(page).to have_content 'Remove source branch' + end + it 'should preserve description textarea height', js: true do long_description = %q( Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam ac ornare ligula, ut tempus arcu. Etiam ultricies accumsan dolor vitae faucibus. Donec at elit lacus. Mauris orci ante, aliquam quis lorem eget, convallis faucibus arcu. Aenean at pulvinar lacus. Ut viverra quam massa, molestie ornare tortor dignissim a. Suspendisse tristique pellentesque tellus, id lacinia metus elementum id. Nam tristique, arcu rhoncus faucibus viverra, lacus ipsum sagittis ligula, vitae convallis odio lacus a nibh. Ut tincidunt est purus, ac vestibulum augue maximus in. Suspendisse vel erat et mi ultricies semper. Pellentesque volutpat pellentesque consequat. diff --git a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb b/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb index e08721b4724af213c0a32ad15ee2a2d9824469e8..09f889d4dd6b71b7f08d2ac6f772e7e35c0f2b7f 100644 --- a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb +++ b/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb @@ -7,7 +7,8 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do let(:merge_request) do create(:merge_request_with_diffs, source_project: project, author: user, - title: 'Bug NS-04') + title: 'Bug NS-04', + merge_params: { force_remove_source_branch: '1' }) end let(:pipeline) do @@ -41,7 +42,7 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do click_button "Merge when pipeline succeeds" expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds." - expect(page).to have_content "The source branch will be removed." + expect(page).to have_content "The source branch will not be removed." expect(page).to have_selector ".js-cancel-auto-merge" visit_merge_request(merge_request) # Needed to refresh the page expect(page).to have_content /enabled an automatic merge when the pipeline for \h{8} succeeds/i @@ -82,7 +83,8 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do source_project: project, title: 'Bug NS-04', author: user, - merge_user: user) + merge_user: user, + merge_params: { force_remove_source_branch: '1' }) end before do @@ -99,7 +101,7 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do click_link 'Merge when pipeline succeeds' expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds." - expect(page).to have_content "The source branch will be removed." + expect(page).to have_content "The source branch will not be removed." expect(page).to have_link "Cancel automatic merge" end end diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb index be8b1423c20299623ec415e8a45835ac99d9aba0..4f3a5119915538553bc755b8c693127ec7d427c5 100644 --- a/spec/features/merge_requests/widget_spec.rb +++ b/spec/features/merge_requests/widget_spec.rb @@ -202,4 +202,25 @@ describe 'Merge request', :feature, :js do end end end + + context 'user can merge into source project but cannot push to fork', js: true do + let(:fork_project) { create(:project, :public) } + let(:user2) { create(:user) } + + before do + project.team << [user2, :master] + logout + login_as user2 + merge_request.update(target_project: fork_project) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'user can merge into the source project' do + expect(page).to have_button('Merge', disabled: false) + end + + it 'user cannot remove source branch' do + expect(page).to have_field('remove-source-branch-input', disabled: true) + end + end end diff --git a/spec/features/projects/builds_spec.rb b/spec/features/projects/builds_spec.rb index a3dd8a4edd281a581aad24a2f9df06167ec8fc08..147e667db07a3ce2f67fef0d49cd2385d6f1b7f9 100644 --- a/spec/features/projects/builds_spec.rb +++ b/spec/features/projects/builds_spec.rb @@ -191,7 +191,7 @@ feature 'Jobs', :feature do end it do - expect(page).to have_link 'Raw' + expect(page).to have_css('.js-raw-link') end end @@ -370,14 +370,14 @@ feature 'Jobs', :feature do end end - describe 'GET /:project/builds/:id/raw' do + describe 'GET /:project/builds/:id/raw', :js do context 'access source' do context 'build from project' do before do - Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') + Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' } build.run! visit namespace_project_job_path(project.namespace, project, build) - page.within('.js-build-sidebar') { click_link 'Raw' } + find('.js-raw-link-controller').click() end it 'sends the right headers' do @@ -389,7 +389,7 @@ feature 'Jobs', :feature do context 'build from other project' do before do - Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') + Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' } build2.run! visit raw_namespace_project_job_path(project.namespace, project, build2) end @@ -404,7 +404,7 @@ feature 'Jobs', :feature do let(:existing_file) { Tempfile.new('existing-trace-file').path } before do - Capybara.current_session.driver.header('X-Sendfile-Type', 'X-Sendfile') + Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' } build.run! @@ -414,13 +414,13 @@ feature 'Jobs', :feature do visit namespace_project_job_path(project.namespace, project, build) end - context 'when build has trace in file' do + context 'when build has trace in file', :js do let(:paths) do [existing_file] end before do - page.within('.js-build-sidebar') { click_link 'Raw' } + find('.js-raw-link-controller').click() end it 'sends the right headers' do @@ -434,7 +434,7 @@ feature 'Jobs', :feature do let(:paths) { [] } it 'sends the right headers' do - expect(page.status_code).not_to have_link('Raw') + expect(page.status_code).not_to have_selector('.js-raw-link-controller') end end end diff --git a/spec/features/projects/developer_views_empty_project_instructions_spec.rb b/spec/features/projects/developer_views_empty_project_instructions_spec.rb index 2352329d58c3a77a70933cade72ccea2b0dafe19..0c51fe72ca4e4b97a6c77f65038c9c60ab64f2dd 100644 --- a/spec/features/projects/developer_views_empty_project_instructions_spec.rb +++ b/spec/features/projects/developer_views_empty_project_instructions_spec.rb @@ -56,14 +56,8 @@ feature 'Developer views empty project instructions', feature: true do end def expect_instructions_for(protocol) - url = - case protocol - when 'ssh' - project.ssh_url_to_repo - when 'http' - project.http_url_to_repo(developer) - end - - expect(page).to have_content("git clone #{url}") + msg = :"#{protocol.downcase}_url_to_repo" + + expect(page).to have_content("git clone #{project.send(msg)}") end end diff --git a/spec/features/projects/group_links_spec.rb b/spec/features/projects/group_links_spec.rb index c969acc9140482285e432ee0da3a1363ba025c8d..4e5682c8636c15dad2e9faffe6a337254bc0202f 100644 --- a/spec/features/projects/group_links_spec.rb +++ b/spec/features/projects/group_links_spec.rb @@ -40,7 +40,7 @@ feature 'Project group links', :feature, :js do another_group.add_master(master) end - it 'does not show ancestors' do + it 'does not show ancestors', :nested_groups do visit namespace_project_settings_members_path(project.namespace, project) click_link 'Search for a group' diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb index b7ae5f0b9258af4ca520aeaf320f7a9424c2e0c3..d428f6fcf2237941810e8228d1b7f229ad5cd95f 100644 --- a/spec/features/projects/members/sorting_spec.rb +++ b/spec/features/projects/members/sorting_spec.rb @@ -3,10 +3,9 @@ require 'spec_helper' feature 'Projects > Members > Sorting', feature: true do let(:master) { create(:user, name: 'John Doe') } let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) } - let(:project) { create(:empty_project) } + let(:project) { create(:empty_project, namespace: master.namespace, creator: master) } background do - create(:project_member, :master, user: master, project: project, created_at: 5.days.ago) create(:project_member, :developer, user: developer, project: project, created_at: 3.days.ago) login_as(master) @@ -39,16 +38,16 @@ feature 'Projects > Members > Sorting', feature: true do scenario 'sorts by last joined' do visit_members_list(sort: :last_joined) - expect(first_member).to include(developer.name) - expect(second_member).to include(master.name) + expect(first_member).to include(master.name) + expect(second_member).to include(developer.name) expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Last joined') end scenario 'sorts by oldest joined' do visit_members_list(sort: :oldest_joined) - expect(first_member).to include(master.name) - expect(second_member).to include(developer.name) + expect(first_member).to include(developer.name) + expect(second_member).to include(master.name) expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined') end diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb index 1bf8f710b9fe6ee01cb5c015c6c24b2d37f96159..ec48a4bd726ab29114780ff4285419ecd767a876 100644 --- a/spec/features/projects/members/user_requests_access_spec.rb +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -2,11 +2,10 @@ require 'spec_helper' feature 'Projects > Members > User requests access', feature: true do let(:user) { create(:user) } - let(:master) { create(:user) } let(:project) { create(:project, :public, :access_requestable) } + let(:master) { project.owner } background do - project.team << [master, :master] login_as(user) visit namespace_project_path(project.namespace, project) end diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb index f40e1bc49306cebc26b93244ed6c54a9b18e84cc..317949d6b565059189d29d86262bebd7695f6ebf 100644 --- a/spec/features/projects/pipeline_schedules_spec.rb +++ b/spec/features/projects/pipeline_schedules_spec.rb @@ -65,6 +65,17 @@ feature 'Pipeline Schedules', :feature do expect(page).not_to have_content('pipeline schedule') end end + + context 'when ref is nil' do + before do + pipeline_schedule.update_attribute(:ref, nil) + visit_pipelines_schedules + end + + it 'shows a list of the pipeline schedules with empty ref column' do + expect(first('.branch-name-cell').text).to eq('') + end + end end describe 'POST /projects/pipeline_schedules/new', js: true do @@ -108,6 +119,19 @@ feature 'Pipeline Schedules', :feature do expect(page).to have_content('my brand new description') end + + context 'when ref is nil' do + before do + pipeline_schedule.update_attribute(:ref, nil) + edit_pipeline_schedule + end + + it 'shows the pipeline schedule with default ref' do + page.within('.git-revision-dropdown-toggle') do + expect(first('.dropdown-toggle-text').text).to eq('master') + end + end + end end def visit_new_pipeline_schedule diff --git a/spec/features/projects/sub_group_issuables_spec.rb b/spec/features/projects/sub_group_issuables_spec.rb index cf21b208f65e951482607fe3fbbbe28cda7c85b4..e88907b801611216bc75a4d8dfb4a6ef3b002315 100644 --- a/spec/features/projects/sub_group_issuables_spec.rb +++ b/spec/features/projects/sub_group_issuables_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'Subgroup Issuables', :feature, :js do +describe 'Subgroup Issuables', :feature, :js, :nested_groups do let!(:group) { create(:group, name: 'group') } let!(:subgroup) { create(:group, parent: group, name: 'subgroup') } let!(:project) { create(:empty_project, namespace: subgroup, name: 'project') } diff --git a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb index 6825b95c8aa3ddfb153636cb6d00c0701ae620fc..95826e7e5be0cc902fbf78fa817df8dff7e55bce 100644 --- a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb @@ -21,6 +21,6 @@ describe 'Projects > Wiki > User views Git access wiki page', :feature do click_link 'Clone repository' expect(page).to have_text("Clone repository #{project.wiki.path_with_namespace}") - expect(page).to have_text(project.wiki.http_url_to_repo(user)) + expect(page).to have_text(project.wiki.http_url_to_repo) end end diff --git a/spec/finders/group_members_finder_spec.rb b/spec/finders/group_members_finder_spec.rb index b762756f9ce2358a5de4fbc5f1b581e396d1d61d..db3fcc2347563825f3c1250d484539689fd3bdcd 100644 --- a/spec/finders/group_members_finder_spec.rb +++ b/spec/finders/group_members_finder_spec.rb @@ -18,7 +18,7 @@ describe GroupMembersFinder, '#execute' do expect(result.to_a).to eq([member3, member2, member1]) end - it 'returns members for nested group' do + it 'returns members for nested group', :nested_groups do group.add_master(user2) nested_group.request_access(user4) member1 = group.add_master(user1) diff --git a/spec/finders/members_finder_spec.rb b/spec/finders/members_finder_spec.rb index cf691cf684b72351ca00be301c1001cfa0c8fefe..300ba8422e883d572647f6609407f2c4157bb12e 100644 --- a/spec/finders/members_finder_spec.rb +++ b/spec/finders/members_finder_spec.rb @@ -9,7 +9,7 @@ describe MembersFinder, '#execute' do let(:user3) { create(:user) } let(:user4) { create(:user) } - it 'returns members for project and parent groups' do + it 'returns members for project and parent groups', :nested_groups do nested_group.request_access(user1) member1 = group.add_master(user2) member2 = nested_group.add_master(user3) diff --git a/spec/fixtures/api/schemas/entities/merge_request.json b/spec/fixtures/api/schemas/entities/merge_request.json index 4afbb87453e07daa4d7a8241ad0a22eac4a6a989..b6a59a6cc4739732b22008ef20737a006dbe1d79 100644 --- a/spec/fixtures/api/schemas/entities/merge_request.json +++ b/spec/fixtures/api/schemas/entities/merge_request.json @@ -92,7 +92,8 @@ "diverged_commits_count": { "type": "integer" }, "commit_change_content_path": { "type": "string" }, "remove_wip_path": { "type": "string" }, - "commits_count": { "type": "integer" } + "commits_count": { "type": "integer" }, + "remove_source_branch": { "type": ["boolean", "null"] } }, "additionalProperties": false } diff --git a/spec/helpers/submodule_helper_spec.rb b/spec/helpers/submodule_helper_spec.rb index 18935be95c99fce290608f199020ea2450086fde..b05ae5c223236cf56ee720f98803e4c5db4f56b8 100644 --- a/spec/helpers/submodule_helper_spec.rb +++ b/spec/helpers/submodule_helper_spec.rb @@ -115,6 +115,11 @@ describe SubmoduleHelper do expect(submodule_links(submodule_item)).to eq(['https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash']) end + it 'handles urls with trailing whitespace' do + stub_url('http://gitlab.com/gitlab-org/gitlab-ce.git ') + expect(submodule_links(submodule_item)).to eq(['https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash']) + end + it 'returns original with non-standard url' do stub_url('http://gitlab.com/another/gitlab-org/gitlab-ce.git') expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil]) diff --git a/spec/javascripts/build_spec.js b/spec/javascripts/build_spec.js index 7d02f568cebfc7edaab12c561769ba0a1cb6a99b..461908f3fdefaed93713a5556707dd703d8fb653 100644 --- a/spec/javascripts/build_spec.js +++ b/spec/javascripts/build_spec.js @@ -14,7 +14,6 @@ describe('Build', () => { beforeEach(() => { loadFixtures('builds/build-with-artifacts.html.raw'); - spyOn($, 'ajax'); }); describe('class constructor', () => { @@ -33,7 +32,6 @@ describe('Build', () => { it('copies build options', function () { expect(this.build.pageUrl).toBe(BUILD_URL); - expect(this.build.buildUrl).toBe(`${BUILD_URL}.json`); expect(this.build.buildStatus).toBe('success'); expect(this.build.buildStage).toBe('test'); expect(this.build.state).toBe(''); @@ -65,27 +63,14 @@ describe('Build', () => { }); describe('running build', () => { - beforeEach(function () { - this.build = new Build(); - }); - it('updates the build trace on an interval', function () { + const deferred1 = $.Deferred(); + const deferred2 = $.Deferred(); + const deferred3 = $.Deferred(); + spyOn($, 'ajax').and.returnValues(deferred1.promise(), deferred2.promise(), deferred3.promise()); spyOn(gl.utils, 'visitUrl'); - jasmine.clock().tick(4001); - - expect($.ajax.calls.count()).toBe(1); - - // We have to do it this way to prevent Webpack to fail to compile - // when destructuring assignments and reusing - // the same variables names inside the same scope - let args = $.ajax.calls.argsFor(0)[0]; - - expect(args.url).toBe(`${BUILD_URL}/trace.json`); - expect(args.dataType).toBe('json'); - expect(args.success).toEqual(jasmine.any(Function)); - - args.success.call($, { + deferred1.resolve({ html: '<span>Update<span>', status: 'running', state: 'newstate', @@ -93,20 +78,9 @@ describe('Build', () => { complete: false, }); - expect($('#build-trace .js-build-output').text()).toMatch(/Update/); - expect(this.build.state).toBe('newstate'); - - jasmine.clock().tick(4001); - - expect($.ajax.calls.count()).toBe(3); - - args = $.ajax.calls.argsFor(2)[0]; - expect(args.url).toBe(`${BUILD_URL}/trace.json`); - expect(args.dataType).toBe('json'); - expect(args.data.state).toBe('newstate'); - expect(args.success).toEqual(jasmine.any(Function)); + deferred2.resolve(); - args.success.call($, { + deferred3.resolve({ html: '<span>More</span>', status: 'running', state: 'finalstate', @@ -114,150 +88,222 @@ describe('Build', () => { complete: true, }); + this.build = new Build(); + + expect($('#build-trace .js-build-output').text()).toMatch(/Update/); + expect(this.build.state).toBe('newstate'); + + jasmine.clock().tick(4001); + expect($('#build-trace .js-build-output').text()).toMatch(/UpdateMore/); expect(this.build.state).toBe('finalstate'); }); it('replaces the entire build trace', () => { + const deferred1 = $.Deferred(); + const deferred2 = $.Deferred(); + const deferred3 = $.Deferred(); + + spyOn($, 'ajax').and.returnValues(deferred1.promise(), deferred2.promise(), deferred3.promise()); + spyOn(gl.utils, 'visitUrl'); - jasmine.clock().tick(4001); - let args = $.ajax.calls.argsFor(0)[0]; - args.success.call($, { - html: '<span>Update</span>', + deferred1.resolve({ + html: '<span>Update<span>', status: 'running', append: false, complete: false, }); - expect($('#build-trace .js-build-output').text()).toMatch(/Update/); + deferred2.resolve(); - jasmine.clock().tick(4001); - args = $.ajax.calls.argsFor(2)[0]; - args.success.call($, { + deferred3.resolve({ html: '<span>Different</span>', status: 'running', append: false, }); + this.build = new Build(); + + expect($('#build-trace .js-build-output').text()).toMatch(/Update/); + + jasmine.clock().tick(4001); + expect($('#build-trace .js-build-output').text()).not.toMatch(/Update/); expect($('#build-trace .js-build-output').text()).toMatch(/Different/); }); it('reloads the page when the build is done', () => { spyOn(gl.utils, 'visitUrl'); + const deferred = $.Deferred(); - jasmine.clock().tick(4001); - const [{ success }] = $.ajax.calls.argsFor(0); - success.call($, { + spyOn($, 'ajax').and.returnValue(deferred.promise()); + deferred.resolve({ html: '<span>Final</span>', status: 'passed', append: true, complete: true, }); + this.build = new Build(); + expect(gl.utils.visitUrl).toHaveBeenCalledWith(BUILD_URL); }); + }); - describe('truncated information', () => { - describe('when size is less than total', () => { - it('shows information about truncated log', () => { - jasmine.clock().tick(4001); - const [{ success }] = $.ajax.calls.argsFor(0); - - success.call($, { - html: '<span>Update</span>', - status: 'success', - append: false, - size: 50, - total: 100, - }); - - expect(document.querySelector('.js-truncated-info').classList).not.toContain('hidden'); + describe('truncated information', () => { + describe('when size is less than total', () => { + it('shows information about truncated log', () => { + spyOn(gl.utils, 'visitUrl'); + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + + deferred.resolve({ + html: '<span>Update</span>', + status: 'success', + append: false, + size: 50, + total: 100, }); - it('shows the size in KiB', () => { - jasmine.clock().tick(4001); - const [{ success }] = $.ajax.calls.argsFor(0); - const size = 50; - - success.call($, { - html: '<span>Update</span>', - status: 'success', - append: false, - size, - total: 100, - }); - - expect( - document.querySelector('.js-truncated-info-size').textContent.trim(), - ).toEqual(`${bytesToKiB(size)}`); + this.build = new Build(); + + expect(document.querySelector('.js-truncated-info').classList).not.toContain('hidden'); + }); + + it('shows the size in KiB', () => { + const size = 50; + spyOn(gl.utils, 'visitUrl'); + const deferred = $.Deferred(); + + spyOn($, 'ajax').and.returnValue(deferred.promise()); + deferred.resolve({ + html: '<span>Update</span>', + status: 'success', + append: false, + size, + total: 100, }); - it('shows incremented size', () => { - jasmine.clock().tick(4001); - let args = $.ajax.calls.argsFor(0)[0]; - args.success.call($, { - html: '<span>Update</span>', - status: 'success', - append: false, - size: 50, - total: 100, - }); - - expect( - document.querySelector('.js-truncated-info-size').textContent.trim(), - ).toEqual(`${bytesToKiB(50)}`); - - jasmine.clock().tick(4001); - args = $.ajax.calls.argsFor(2)[0]; - args.success.call($, { - html: '<span>Update</span>', - status: 'success', - append: true, - size: 10, - total: 100, - }); - - expect( - document.querySelector('.js-truncated-info-size').textContent.trim(), - ).toEqual(`${bytesToKiB(60)}`); + this.build = new Build(); + + expect( + document.querySelector('.js-truncated-info-size').textContent.trim(), + ).toEqual(`${bytesToKiB(size)}`); + }); + + it('shows incremented size', () => { + const deferred1 = $.Deferred(); + const deferred2 = $.Deferred(); + const deferred3 = $.Deferred(); + + spyOn($, 'ajax').and.returnValues(deferred1.promise(), deferred2.promise(), deferred3.promise()); + + spyOn(gl.utils, 'visitUrl'); + + deferred1.resolve({ + html: '<span>Update</span>', + status: 'success', + append: false, + size: 50, + total: 100, }); - it('renders the raw link', () => { - jasmine.clock().tick(4001); - const [{ success }] = $.ajax.calls.argsFor(0); - - success.call($, { - html: '<span>Update</span>', - status: 'success', - append: false, - size: 50, - total: 100, - }); - - expect( - document.querySelector('.js-raw-link').textContent.trim(), - ).toContain('Complete Raw'); + deferred2.resolve(); + + this.build = new Build(); + + expect( + document.querySelector('.js-truncated-info-size').textContent.trim(), + ).toEqual(`${bytesToKiB(50)}`); + + jasmine.clock().tick(4001); + + deferred3.resolve({ + html: '<span>Update</span>', + status: 'success', + append: true, + size: 10, + total: 100, }); + + expect( + document.querySelector('.js-truncated-info-size').textContent.trim(), + ).toEqual(`${bytesToKiB(60)}`); }); - describe('when size is equal than total', () => { - it('does not show the trunctated information', () => { - jasmine.clock().tick(4001); - const [{ success }] = $.ajax.calls.argsFor(0); + it('renders the raw link', () => { + const deferred = $.Deferred(); + spyOn(gl.utils, 'visitUrl'); + + spyOn($, 'ajax').and.returnValue(deferred.promise()); + deferred.resolve({ + html: '<span>Update</span>', + status: 'success', + append: false, + size: 50, + total: 100, + }); - success.call($, { - html: '<span>Update</span>', - status: 'success', - append: false, - size: 100, - total: 100, - }); + this.build = new Build(); - expect(document.querySelector('.js-truncated-info').classList).toContain('hidden'); + expect( + document.querySelector('.js-raw-link').textContent.trim(), + ).toContain('Complete Raw'); + }); + }); + + describe('when size is equal than total', () => { + it('does not show the trunctated information', () => { + const deferred = $.Deferred(); + spyOn(gl.utils, 'visitUrl'); + + spyOn($, 'ajax').and.returnValue(deferred.promise()); + deferred.resolve({ + html: '<span>Update</span>', + status: 'success', + append: false, + size: 100, + total: 100, }); + + this.build = new Build(); + + expect(document.querySelector('.js-truncated-info').classList).toContain('hidden'); + }); + }); + }); + + describe('output trace', () => { + beforeEach(() => { + const deferred = $.Deferred(); + spyOn(gl.utils, 'visitUrl'); + + spyOn($, 'ajax').and.returnValue(deferred.promise()); + deferred.resolve({ + html: '<span>Update</span>', + status: 'success', + append: false, + size: 50, + total: 100, }); + + this.build = new Build(); + }); + + it('should render trace controls', () => { + const controllers = document.querySelector('.controllers'); + + expect(controllers.querySelector('.js-raw-link-controller')).toBeDefined(); + expect(controllers.querySelector('.js-erase-link')).toBeDefined(); + expect(controllers.querySelector('.js-scroll-up')).toBeDefined(); + expect(controllers.querySelector('.js-scroll-down')).toBeDefined(); + }); + + it('should render received output', () => { + expect( + document.querySelector('.js-build-output').innerHTML, + ).toEqual('<span>Update</span>'); }); }); }); diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index 8688332782dc68e30292327bfe0a18e57209d081..6e59ee96c6b87f0f18f0aa8478436178446934b6 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -57,6 +57,7 @@ describe('Filtered Search Manager', () => { input = document.querySelector('.filtered-search'); tokensContainer = document.querySelector('.tokens-container'); manager = new gl.FilteredSearchManager(); + manager.setup(); }); afterEach(() => { @@ -72,6 +73,7 @@ describe('Filtered Search Manager', () => { spyOn(recentSearchesStoreSrc, 'default'); filteredSearchManager = new gl.FilteredSearchManager(); + filteredSearchManager.setup(); return filteredSearchManager; }); @@ -89,6 +91,7 @@ describe('Filtered Search Manager', () => { spyOn(window, 'Flash'); filteredSearchManager = new gl.FilteredSearchManager(); + filteredSearchManager.setup(); expect(window.Flash).not.toHaveBeenCalled(); }); diff --git a/spec/javascripts/lib/utils/number_utility_spec.js b/spec/javascripts/lib/utils/number_utility_spec.js index 90b12c9f1153776fd21c3e15fb5676cea3163abc..83c92deccdceeaf488232b6e927eecdb548e81b0 100644 --- a/spec/javascripts/lib/utils/number_utility_spec.js +++ b/spec/javascripts/lib/utils/number_utility_spec.js @@ -1,4 +1,4 @@ -import { formatRelevantDigits, bytesToKiB } from '~/lib/utils/number_utils'; +import { formatRelevantDigits, bytesToKiB, bytesToMiB } from '~/lib/utils/number_utils'; describe('Number Utils', () => { describe('formatRelevantDigits', () => { @@ -45,4 +45,11 @@ describe('Number Utils', () => { expect(bytesToKiB(1000)).toEqual(0.9765625); }); }); + + describe('bytesToMiB', () => { + it('calculates MiB for the given bytes', () => { + expect(bytesToMiB(1048576)).toEqual(1); + expect(bytesToMiB(1000000)).toEqual(0.95367431640625); + }); + }); }); diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js index 3d1706aab686ae2a9151123e08e3283b1d91bdf1..7b910282cc88d62fa9cafe2d4ec988c0dcba7046 100644 --- a/spec/javascripts/merge_request_tabs_spec.js +++ b/spec/javascripts/merge_request_tabs_spec.js @@ -1,4 +1,5 @@ /* eslint-disable no-var, comma-dangle, object-shorthand */ +/* global Notes */ import '~/merge_request_tabs'; import '~/commit/pipelines/pipelines_bundle'; @@ -7,6 +8,7 @@ import '~/lib/utils/common_utils'; import '~/diff'; import '~/single_file_diff'; import '~/files_comment_button'; +import '~/notes'; import 'vendor/jquery.scrollTo'; (function () { @@ -29,7 +31,7 @@ import 'vendor/jquery.scrollTo'; }; $.extend(stubLocation, defaults, stubs || {}); }; - preloadFixtures('merge_requests/merge_request_with_task_list.html.raw'); + preloadFixtures('merge_requests/merge_request_with_task_list.html.raw', 'merge_requests/diff_comment.html.raw'); beforeEach(function () { this.class = new gl.MergeRequestTabs({ stubLocation: stubLocation }); @@ -286,8 +288,49 @@ import 'vendor/jquery.scrollTo'; spyOn($, 'ajax').and.callFake(function (options) { expect(options.url).toEqual('/foo/bar/merge_requests/1/diffs.json'); }); + this.class.loadDiff('/foo/bar/merge_requests/1/diffs'); }); + + describe('with note fragment hash', () => { + beforeEach(() => { + loadFixtures('merge_requests/diff_comment.html.raw'); + spyOn(window.gl.utils, 'getPagePath').and.returnValue('merge_requests'); + window.notes = new Notes('', []); + spyOn(window.notes, 'toggleDiffNote').and.callThrough(); + }); + + afterEach(() => { + delete window.notes; + }); + + it('should expand and scroll to linked fragment hash #note_xxx', function () { + const noteId = 'note_1'; + spyOn(window.gl.utils, 'getLocationHash').and.returnValue(noteId); + spyOn($, 'ajax').and.callFake(function (options) { + options.success({ html: `<div id="${noteId}">foo</div>` }); + }); + + this.class.loadDiff('/foo/bar/merge_requests/1/diffs'); + + expect(window.notes.toggleDiffNote).toHaveBeenCalledWith({ + target: jasmine.any(Object), + lineType: 'old', + forceShow: true, + }); + }); + + it('should gracefully ignore non-existant fragment hash', function () { + spyOn(window.gl.utils, 'getLocationHash').and.returnValue('note_something-that-does-not-exist'); + spyOn($, 'ajax').and.callFake(function (options) { + options.success({ html: '' }); + }); + + this.class.loadDiff('/foo/bar/merge_requests/1/diffs'); + + expect(window.notes.toggleDiffNote).not.toHaveBeenCalled(); + }); + }); }); }); }).call(window); diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index 025f08ee3323379c1fff484e205ea5e921e304ca..04cf0fe2bf8fb7c52047d08cb88122b7dcefe637 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -128,7 +128,6 @@ import '~/notes'; beforeEach(() => { note = { id: 1, - discussion_html: null, valid: true, note: 'heya', html: '<div>heya</div>', diff --git a/spec/javascripts/pipelines/graph/graph_component_spec.js b/spec/javascripts/pipelines/graph/graph_component_spec.js index 6bd0eb86263346dd77c9d9bb5825af15264d4d42..713baa65a172ceab7d4a32a1a6957b85e83fe92c 100644 --- a/spec/javascripts/pipelines/graph/graph_component_spec.js +++ b/spec/javascripts/pipelines/graph/graph_component_spec.js @@ -14,49 +14,42 @@ describe('graph component', () => { describe('while is loading', () => { it('should render a loading icon', () => { - const component = new GraphComponent().$mount('#js-pipeline-graph-vue'); + const component = new GraphComponent({ + propsData: { + isLoading: true, + pipeline: {}, + }, + }).$mount('#js-pipeline-graph-vue'); expect(component.$el.querySelector('.loading-icon')).toBeDefined(); }); }); - describe('with a successfull response', () => { - const interceptor = (request, next) => { - next(request.respondWith(JSON.stringify(graphJSON), { - status: 200, - })); - }; + describe('with data', () => { + it('should render the graph', () => { + const component = new GraphComponent({ + propsData: { + isLoading: false, + pipeline: graphJSON, + }, + }).$mount('#js-pipeline-graph-vue'); - beforeEach(() => { - Vue.http.interceptors.push(interceptor); - }); - - afterEach(() => { - Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); - }); - - it('should render the graph', (done) => { - const component = new GraphComponent().$mount('#js-pipeline-graph-vue'); - - setTimeout(() => { - expect(component.$el.classList.contains('js-pipeline-graph')).toEqual(true); + expect(component.$el.classList.contains('js-pipeline-graph')).toEqual(true); - expect( - component.$el.querySelector('.stage-column:first-child').classList.contains('no-margin'), - ).toEqual(true); + expect( + component.$el.querySelector('.stage-column:first-child').classList.contains('no-margin'), + ).toEqual(true); - expect( - component.$el.querySelector('.stage-column:nth-child(2)').classList.contains('left-margin'), - ).toEqual(true); + expect( + component.$el.querySelector('.stage-column:nth-child(2)').classList.contains('left-margin'), + ).toEqual(true); - expect( - component.$el.querySelector('.stage-column:nth-child(2) .build:nth-child(1)').classList.contains('left-connector'), - ).toEqual(true); + expect( + component.$el.querySelector('.stage-column:nth-child(2) .build:nth-child(1)').classList.contains('left-connector'), + ).toEqual(true); - expect(component.$el.querySelector('loading-icon')).toBe(null); + expect(component.$el.querySelector('loading-icon')).toBe(null); - expect(component.$el.querySelector('.stage-column-list')).toBeDefined(); - done(); - }, 0); + expect(component.$el.querySelector('.stage-column-list')).toBeDefined(); }); }); }); diff --git a/spec/javascripts/pipelines/pipeline_url_spec.js b/spec/javascripts/pipelines/pipeline_url_spec.js index 0bcc390570262d3c75e476823664642162832736..d74b12816682d134901e9bfb3cf807d3525a662f 100644 --- a/spec/javascripts/pipelines/pipeline_url_spec.js +++ b/spec/javascripts/pipelines/pipeline_url_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import pipelineUrlComp from '~/pipelines/components/pipeline_url'; +import pipelineUrlComp from '~/pipelines/components/pipeline_url.vue'; describe('Pipeline Url Component', () => { let PipelineUrlComponent; diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js index da9dff18ada254eed5855f88600c8839b99484d0..2c3d0ddff28ea488af30e2131fe74e77aea61dae 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js @@ -7,6 +7,18 @@ const url = '/root/acets-review-apps/environments/15/deployments/1/metrics'; const metricsMockData = { success: true, metrics: { + memory_before: [ + { + metric: {}, + value: [1495785220.607, '9572875.906976745'], + }, + ], + memory_after: [ + { + metric: {}, + value: [1495787020.607, '4485853.130206379'], + }, + ], memory_values: [ { metric: {}, @@ -39,7 +51,7 @@ const createComponent = () => { const messages = { loadingMetrics: 'Loading deployment statistics.', - hasMetrics: 'Deployment memory usage:', + hasMetrics: 'Memory usage unchanged from 0MB to 0MB', loadFailed: 'Failed to load deployment statistics.', metricsUnavailable: 'Deployment statistics are not available currently.', }; @@ -89,17 +101,52 @@ describe('MemoryUsage', () => { }); }); + describe('computed', () => { + describe('memoryChangeType', () => { + it('should return "increased" if memoryFrom value is less than memoryTo value', () => { + vm.memoryFrom = 4.28; + vm.memoryTo = 9.13; + + expect(vm.memoryChangeType).toEqual('increased'); + }); + + it('should return "decreased" if memoryFrom value is less than memoryTo value', () => { + vm.memoryFrom = 9.13; + vm.memoryTo = 4.28; + + expect(vm.memoryChangeType).toEqual('decreased'); + }); + + it('should return "unchanged" if memoryFrom value equal to memoryTo value', () => { + vm.memoryFrom = 1; + vm.memoryTo = 1; + + expect(vm.memoryChangeType).toEqual('unchanged'); + }); + }); + }); + describe('methods', () => { const { metrics, deployment_time } = metricsMockData; + describe('getMegabytes', () => { + it('should return Megabytes from provided Bytes value', () => { + const memoryInBytes = '9572875.906976745'; + + expect(vm.getMegabytes(memoryInBytes)).toEqual('9.13'); + }); + }); + describe('computeGraphData', () => { it('should populate sparkline graph', () => { vm.computeGraphData(metrics, deployment_time); - const { hasMetrics, memoryMetrics, deploymentTime } = vm; + const { hasMetrics, memoryMetrics, deploymentTime, memoryFrom, memoryTo } = vm; expect(hasMetrics).toBeTruthy(); expect(memoryMetrics.length > 0).toBeTruthy(); expect(deploymentTime).toEqual(deployment_time); + expect(memoryFrom).toEqual('9.13'); + expect(memoryTo).toEqual('4.28'); }); }); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index d043ad38b8bd771ce44cd3337b7d206b94242eef..732b516badd301a9c038fe1c5019dbf52f2eb175 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -5,7 +5,7 @@ import * as simplePoll from '~/lib/utils/simple_poll'; const commitMessage = 'This is the commit message'; const commitMessageWithDescription = 'This is the commit message description'; -const createComponent = () => { +const createComponent = (customConfig = {}) => { const Component = Vue.extend(readyToMergeComponent); const mr = { isPipelineActive: false, @@ -17,8 +17,12 @@ const createComponent = () => { sha: '12345678', commitMessage, commitMessageWithDescription, + shouldRemoveSourceBranch: true, + canRemoveSourceBranch: false, }; + Object.assign(mr, customConfig.mr); + const service = { merge() {}, poll() {}, @@ -51,7 +55,6 @@ describe('MRWidgetReadyToMerge', () => { describe('data', () => { it('should have default data', () => { - expect(vm.removeSourceBranch).toBeTruthy(true); expect(vm.mergeWhenBuildSucceeds).toBeFalsy(); expect(vm.useCommitMessageWithDescription).toBeFalsy(); expect(vm.setToMergeWhenPipelineSucceeds).toBeFalsy(); @@ -166,6 +169,36 @@ describe('MRWidgetReadyToMerge', () => { expect(vm.isMergeButtonDisabled).toBeTruthy(); }); }); + + describe('Remove source branch checkbox', () => { + describe('when user can merge but cannot delete branch', () => { + it('isRemoveSourceBranchButtonDisabled should be true', () => { + expect(vm.isRemoveSourceBranchButtonDisabled).toBe(true); + }); + + it('should be disabled in the rendered output', () => { + const checkboxElement = vm.$el.querySelector('#remove-source-branch-input'); + expect(checkboxElement.getAttribute('disabled')).toBe('disabled'); + }); + }); + + describe('when user can merge and can delete branch', () => { + beforeEach(() => { + this.customVm = createComponent({ + mr: { canRemoveSourceBranch: true }, + }); + }); + + it('isRemoveSourceBranchButtonDisabled should be false', () => { + expect(this.customVm.isRemoveSourceBranchButtonDisabled).toBe(false); + }); + + it('should be enabled in rendered output', () => { + const checkboxElement = this.customVm.$el.querySelector('#remove-source-branch-input'); + expect(checkboxElement.getAttribute('disabled')).toBeNull(); + }); + }); + }); }); describe('methods', () => { diff --git a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js index bdc18243a15413ded59472298ff15cc7fa07d389..3a0c50b750ffeb91a54746005e100cbe40992427 100644 --- a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; import mrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options'; import eventHub from '~/vue_merge_request_widget/event_hub'; +import notify from '~/lib/utils/notify'; import mockData from './mock_data'; const createComponent = () => { @@ -107,6 +108,8 @@ describe('mrWidgetOptions', () => { it('should tell service to check status', (done) => { spyOn(vm.service, 'checkStatus').and.returnValue(returnPromise(mockData)); spyOn(vm.mr, 'setData'); + spyOn(vm, 'handleNotification'); + let isCbExecuted = false; const cb = () => { isCbExecuted = true; @@ -117,6 +120,7 @@ describe('mrWidgetOptions', () => { setTimeout(() => { expect(vm.service.checkStatus).toHaveBeenCalled(); expect(vm.mr.setData).toHaveBeenCalled(); + expect(vm.handleNotification).toHaveBeenCalledWith(mockData); expect(isCbExecuted).toBeTruthy(); done(); }, 333); @@ -254,6 +258,39 @@ describe('mrWidgetOptions', () => { }); }); + describe('handleNotification', () => { + const data = { + ci_status: 'running', + title: 'title', + pipeline: { details: { status: { label: 'running-label' } } }, + }; + + beforeEach(() => { + spyOn(notify, 'notifyMe'); + + vm.mr.ciStatus = 'failed'; + vm.mr.gitlabLogo = 'logo.png'; + }); + + it('should call notifyMe', () => { + vm.handleNotification(data); + + expect(notify.notifyMe).toHaveBeenCalledWith( + 'Pipeline running-label', + 'Pipeline running-label for "title"', + 'logo.png', + ); + }); + + it('should not call notifyMe if the status has not changed', () => { + vm.mr.ciStatus = data.ci_status; + + vm.handleNotification(data); + + expect(notify.notifyMe).not.toHaveBeenCalled(); + }); + }); + describe('resumePolling', () => { it('should call stopTimer on pollingInterval', () => { spyOn(vm.pollingInterval, 'resume'); diff --git a/spec/javascripts/vue_shared/components/header_ci_component_spec.js b/spec/javascripts/vue_shared/components/header_ci_component_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1bf8916b3d0ffd8c777c7a458412100bfcba2930 --- /dev/null +++ b/spec/javascripts/vue_shared/components/header_ci_component_spec.js @@ -0,0 +1,82 @@ +import Vue from 'vue'; +import headerCi from '~/vue_shared/components/header_ci_component.vue'; + +describe('Header CI Component', () => { + let HeaderCi; + let vm; + let props; + + beforeEach(() => { + HeaderCi = Vue.extend(headerCi); + + props = { + status: { + group: 'failed', + icon: 'ci-status-failed', + label: 'failed', + text: 'failed', + details_path: 'path', + }, + itemName: 'job', + itemId: 123, + time: '2017-05-08T14:57:39.781Z', + user: { + web_url: 'path', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatar_url: 'link', + }, + actions: [ + { + label: 'Retry', + path: 'path', + type: 'button', + cssClass: 'btn', + }, + { + label: 'Go', + path: 'path', + type: 'link', + cssClass: 'link', + }, + ], + }; + + vm = new HeaderCi({ + propsData: props, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render status badge', () => { + expect(vm.$el.querySelector('.ci-failed')).toBeDefined(); + expect(vm.$el.querySelector('.ci-status-icon-failed svg')).toBeDefined(); + expect( + vm.$el.querySelector('.ci-failed').getAttribute('href'), + ).toEqual(props.status.details_path); + }); + + it('should render item name and id', () => { + expect(vm.$el.querySelector('strong').textContent.trim()).toEqual('job #123'); + }); + + it('should render timeago date', () => { + expect(vm.$el.querySelector('time')).toBeDefined(); + }); + + it('should render user icon and name', () => { + expect(vm.$el.querySelector('.js-user-link').textContent.trim()).toEqual(props.user.name); + }); + + it('should render provided actions', () => { + expect(vm.$el.querySelector('.btn').tagName).toEqual('BUTTON'); + expect(vm.$el.querySelector('.btn').textContent.trim()).toEqual(props.actions[0].label); + expect(vm.$el.querySelector('.link').tagName).toEqual('A'); + expect(vm.$el.querySelector('.link').textContent.trim()).toEqual(props.actions[1].label); + expect(vm.$el.querySelector('.link').getAttribute('href')).toEqual(props.actions[0].path); + }); +}); diff --git a/spec/javascripts/vue_shared/components/time_ago_tooltip_spec.js b/spec/javascripts/vue_shared/components/time_ago_tooltip_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..bf28019ef241a27342409254d6451c5f5da2cc02 --- /dev/null +++ b/spec/javascripts/vue_shared/components/time_ago_tooltip_spec.js @@ -0,0 +1,68 @@ +import Vue from 'vue'; +import timeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import '~/lib/utils/datetime_utility'; + +describe('Time ago with tooltip component', () => { + let TimeagoTooltip; + let vm; + + beforeEach(() => { + TimeagoTooltip = Vue.extend(timeagoTooltip); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render timeago with a bootstrap tooltip', () => { + vm = new TimeagoTooltip({ + propsData: { + time: '2017-05-08T14:57:39.781Z', + }, + }).$mount(); + + expect(vm.$el.tagName).toEqual('TIME'); + expect(vm.$el.classList.contains('js-timeago')).toEqual(true); + expect( + vm.$el.getAttribute('data-original-title'), + ).toEqual(gl.utils.formatDate('2017-05-08T14:57:39.781Z')); + expect(vm.$el.getAttribute('data-placement')).toEqual('top'); + + const timeago = gl.utils.getTimeago(); + + expect(vm.$el.textContent.trim()).toEqual(timeago.format('2017-05-08T14:57:39.781Z')); + }); + + it('should render tooltip placed in bottom', () => { + vm = new TimeagoTooltip({ + propsData: { + time: '2017-05-08T14:57:39.781Z', + tooltipPlacement: 'bottom', + }, + }).$mount(); + + expect(vm.$el.getAttribute('data-placement')).toEqual('bottom'); + }); + + it('should render short format class', () => { + vm = new TimeagoTooltip({ + propsData: { + time: '2017-05-08T14:57:39.781Z', + shortFormat: true, + }, + }).$mount(); + + expect(vm.$el.classList.contains('js-short-timeago')).toEqual(true); + }); + + it('should render provided html class', () => { + vm = new TimeagoTooltip({ + propsData: { + time: '2017-05-08T14:57:39.781Z', + cssClass: 'foo', + }, + }).$mount(); + + expect(vm.$el.classList.contains('foo')).toEqual(true); + }); +}); diff --git a/spec/lib/banzai/reference_parser/user_parser_spec.rb b/spec/lib/banzai/reference_parser/user_parser_spec.rb index 4ec998efe53804dc9ccfdf7c9fc25edef23b3f2b..592ed0d2b982820f6a4b2099a33f5d2942c8c25a 100644 --- a/spec/lib/banzai/reference_parser/user_parser_spec.rb +++ b/spec/lib/banzai/reference_parser/user_parser_spec.rb @@ -42,6 +42,29 @@ describe Banzai::ReferenceParser::UserParser, lib: true do expect(subject.referenced_by([link])).to eq([user]) end + + context 'when RequestStore is active' do + let(:other_user) { create(:user) } + + before do + RequestStore.begin! + end + + after do + RequestStore.end! + RequestStore.clear! + end + + it 'does not return users from the first call in the second' do + link['data-user'] = user.id.to_s + + expect(subject.referenced_by([link])).to eq([user]) + + link['data-user'] = other_user.id.to_s + + expect(subject.referenced_by([link])).to eq([other_user]) + end + end end context 'when the link has a data-project attribute' do @@ -74,7 +97,7 @@ describe Banzai::ReferenceParser::UserParser, lib: true do end end - describe '#nodes_visible_to_use?' do + describe '#nodes_visible_to_user' do context 'when the link has a data-group attribute' do context 'using an existing group ID' do before do diff --git a/spec/lib/gitlab/cache/ci/project_pipeline_status_spec.rb b/spec/lib/gitlab/cache/ci/project_pipeline_status_spec.rb index b386852b1968d8b8ef3283cbff5a6281933276b2..cfb5cba054ec0079b77d47f45ad82a96841f14fe 100644 --- a/spec/lib/gitlab/cache/ci/project_pipeline_status_spec.rb +++ b/spec/lib/gitlab/cache/ci/project_pipeline_status_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do - let(:project) { create(:project) } + let!(:project) { create(:project) } let(:pipeline_status) { described_class.new(project) } let(:cache_key) { "projects/#{project.id}/pipeline_status" } @@ -18,7 +18,7 @@ describe Gitlab::Cache::Ci::ProjectPipelineStatus, :redis do let(:sha) { '424d1b73bc0d3cb726eb7dc4ce17a4d48552f8c6' } let(:ref) { 'master' } let(:pipeline_info) { { sha: sha, status: status, ref: ref } } - let(:project_without_status) { create(:project) } + let!(:project_without_status) { create(:project) } describe '.load_in_batch_for_projects' do it 'preloads pipeline_status on projects' do diff --git a/spec/lib/gitlab/dependency_linker/package_json_linker_spec.rb b/spec/lib/gitlab/dependency_linker/package_json_linker_spec.rb index b5f2c387d6f0e49b182d953c4dd5074464fcfd88..8c979ae1869d784ee790336336df4df2025604ad 100644 --- a/spec/lib/gitlab/dependency_linker/package_json_linker_spec.rb +++ b/spec/lib/gitlab/dependency_linker/package_json_linker_spec.rb @@ -24,12 +24,16 @@ describe Gitlab::DependencyLinker::PackageJsonLinker, lib: true do "url": "https://github.com/vuejs/vue.git" }, "homepage": "https://github.com/vuejs/vue#readme", + "scripts": { + "karma": "karma start config/karma.config.js --single-run" + }, "dependencies": { "primus": "*", "async": "~0.8.0", "express": "4.2.x", "bigpipe": "bigpipe/pagelet", - "plates": "https://github.com/flatiron/plates/tarball/master" + "plates": "https://github.com/flatiron/plates/tarball/master", + "karma": "^1.4.1" }, "devDependencies": { "vows": "^0.7.0", @@ -69,6 +73,7 @@ describe Gitlab::DependencyLinker::PackageJsonLinker, lib: true do expect(subject).to include(link('express', 'https://npmjs.com/package/express')) expect(subject).to include(link('bigpipe', 'https://npmjs.com/package/bigpipe')) expect(subject).to include(link('plates', 'https://npmjs.com/package/plates')) + expect(subject).to include(link('karma', 'https://npmjs.com/package/karma')) expect(subject).to include(link('vows', 'https://npmjs.com/package/vows')) expect(subject).to include(link('assume', 'https://npmjs.com/package/assume')) expect(subject).to include(link('pre-commit', 'https://npmjs.com/package/pre-commit')) @@ -81,5 +86,9 @@ describe Gitlab::DependencyLinker::PackageJsonLinker, lib: true do it 'links Git repos' do expect(subject).to include(link('https://github.com/flatiron/plates/tarball/master', 'https://github.com/flatiron/plates/tarball/master')) end + + it 'does not link scripts with the same key as a package' do + expect(subject).not_to include(link('karma start config/karma.config.js --single-run', 'https://github.com/karma start config/karma.config.js --single-run')) + end end end diff --git a/spec/lib/gitlab/git/diff_collection_spec.rb b/spec/lib/gitlab/git/diff_collection_spec.rb index 122c93dcd690bb146063eb1621abe478bbaae6c5..ae617b313c59b3f53043ebbeb3dfad668480e026 100644 --- a/spec/lib/gitlab/git/diff_collection_spec.rb +++ b/spec/lib/gitlab/git/diff_collection_spec.rb @@ -10,7 +10,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do no_collapse: no_collapse ) end - let(:iterator) { Array.new(file_count, fake_diff(line_length, line_count)) } + let(:iterator) { MutatingConstantIterator.new(file_count, fake_diff(line_length, line_count)) } let(:file_count) { 0 } let(:line_length) { 1 } let(:line_count) { 1 } @@ -64,7 +64,15 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do subject { super().real_size } it { is_expected.to eq('3') } end - it { expect(subject.size).to eq(3) } + + describe '#size' do + it { expect(subject.size).to eq(3) } + + it 'does not change after peeking' do + subject.any? + expect(subject.size).to eq(3) + end + end context 'when limiting is disabled' do let(:all_diffs) { true } @@ -83,7 +91,15 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do subject { super().real_size } it { is_expected.to eq('3') } end - it { expect(subject.size).to eq(3) } + + describe '#size' do + it { expect(subject.size).to eq(3) } + + it 'does not change after peeking' do + subject.any? + expect(subject.size).to eq(3) + end + end end end @@ -457,4 +473,22 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do def fake_diff(line_length, line_count) { 'diff' => "#{'a' * line_length}\n" * line_count } end + + class MutatingConstantIterator + include Enumerable + + def initialize(count, value) + @count = count + @value = value + end + + def each + loop do + break if @count.zero? + # It is critical to decrement before yielding. We may never reach the lines after 'yield'. + @count -= 1 + yield @value + end + end + end end diff --git a/spec/lib/gitlab/git/encoding_helper_spec.rb b/spec/lib/gitlab/git/encoding_helper_spec.rb index 1a3bf802a078beb7f65d385321e2abbafc6639e9..48fc817d8576135a9509059837b40b917f9547f1 100644 --- a/spec/lib/gitlab/git/encoding_helper_spec.rb +++ b/spec/lib/gitlab/git/encoding_helper_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" describe Gitlab::Git::EncodingHelper do let(:ext_class) { Class.new { extend Gitlab::Git::EncodingHelper } } - let(:binary_string) { File.join(SEED_STORAGE_PATH, 'gitlab_logo.png') } + let(:binary_string) { File.read(Rails.root + "spec/fixtures/dk.png") } describe '#encode!' do [ diff --git a/spec/lib/gitlab/group_hierarchy_spec.rb b/spec/lib/gitlab/group_hierarchy_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5d0ed1522b39e391dfbfa544b6be4655dd7c1798 --- /dev/null +++ b/spec/lib/gitlab/group_hierarchy_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' + +describe Gitlab::GroupHierarchy, :postgresql do + let!(:parent) { create(:group) } + let!(:child1) { create(:group, parent: parent) } + let!(:child2) { create(:group, parent: child1) } + + describe '#base_and_ancestors' do + let(:relation) do + described_class.new(Group.where(id: child2.id)).base_and_ancestors + end + + it 'includes the base rows' do + expect(relation).to include(child2) + end + + it 'includes all of the ancestors' do + expect(relation).to include(parent, child1) + end + end + + describe '#base_and_descendants' do + let(:relation) do + described_class.new(Group.where(id: parent.id)).base_and_descendants + end + + it 'includes the base rows' do + expect(relation).to include(parent) + end + + it 'includes all the descendants' do + expect(relation).to include(child1, child2) + end + end + + describe '#all_groups' do + let(:relation) do + described_class.new(Group.where(id: child1.id)).all_groups + end + + it 'includes the base rows' do + expect(relation).to include(child1) + end + + it 'includes the ancestors' do + expect(relation).to include(parent) + end + + it 'includes the descendants' do + expect(relation).to include(child2) + end + end +end diff --git a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb index 45ccd3d6459d6234c5389378642cb3535e8cb733..61c10d47434bf7c6a74b51279bf136474f23e6a7 100644 --- a/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb +++ b/spec/lib/gitlab/health_checks/fs_shards_check_spec.rb @@ -1,6 +1,24 @@ require 'spec_helper' describe Gitlab::HealthChecks::FsShardsCheck do + def command_exists?(command) + _, status = Gitlab::Popen.popen(%W{ #{command} 1 echo }) + status == 0 + rescue Errno::ENOENT + false + end + + def timeout_command + @timeout_command ||= + if command_exists?('timeout') + 'timeout' + elsif command_exists?('gtimeout') + 'gtimeout' + else + '' + end + end + let(:metric_class) { Gitlab::HealthChecks::Metric } let(:result_class) { Gitlab::HealthChecks::Result } let(:repository_storages) { [:default] } @@ -15,6 +33,7 @@ describe Gitlab::HealthChecks::FsShardsCheck do before do allow(described_class).to receive(:repository_storages) { repository_storages } allow(described_class).to receive(:storages_paths) { storages_paths } + stub_const('Gitlab::HealthChecks::FsShardsCheck::TIMEOUT_EXECUTABLE', timeout_command) end after do @@ -78,40 +97,76 @@ describe Gitlab::HealthChecks::FsShardsCheck do }.with_indifferent_access end - it { is_expected.to include(metric_class.new(:filesystem_accessible, 0, shard: :default)) } - it { is_expected.to include(metric_class.new(:filesystem_readable, 0, shard: :default)) } - it { is_expected.to include(metric_class.new(:filesystem_writable, 0, shard: :default)) } + it { is_expected.to all(have_attributes(labels: { shard: :default })) } + + it { is_expected.to include(an_object_having_attributes(name: :filesystem_accessible, value: 0)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_readable, value: 0)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_writable, value: 0)) } - it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be >= 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be >= 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_access_latency, value: be >= 0)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_read_latency, value: be >= 0)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_write_latency, value: be >= 0)) } end context 'storage points to directory that has both read and write rights' do before do FileUtils.chmod_R(0755, tmp_dir) end + it { is_expected.to all(have_attributes(labels: { shard: :default })) } - it { is_expected.to include(metric_class.new(:filesystem_accessible, 1, shard: :default)) } - it { is_expected.to include(metric_class.new(:filesystem_readable, 1, shard: :default)) } - it { is_expected.to include(metric_class.new(:filesystem_writable, 1, shard: :default)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_accessible, value: 1)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_readable, value: 1)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_writable, value: 1)) } - it { is_expected.to include(have_attributes(name: :filesystem_access_latency, value: be >= 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_read_latency, value: be >= 0, labels: { shard: :default })) } - it { is_expected.to include(have_attributes(name: :filesystem_write_latency, value: be >= 0, labels: { shard: :default })) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_access_latency, value: be >= 0)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_read_latency, value: be >= 0)) } + it { is_expected.to include(an_object_having_attributes(name: :filesystem_write_latency, value: be >= 0)) } + end + end + end + + context 'when timeout kills fs checks' do + before do + stub_const('Gitlab::HealthChecks::FsShardsCheck::COMMAND_TIMEOUT', '1') + + allow(described_class).to receive(:exec_with_timeout).and_wrap_original { |m| m.call(%w(sleep 60)) } + FileUtils.chmod_R(0755, tmp_dir) + end + + describe '#readiness' do + subject { described_class.readiness } + + it { is_expected.to include(result_class.new(false, 'cannot stat storage', shard: :default)) } + end + + describe '#metrics' do + subject { described_class.metrics } + + it 'provides metrics' do + expect(subject).to all(have_attributes(labels: { shard: :default })) + + expect(subject).to include(an_object_having_attributes(name: :filesystem_accessible, value: 0)) + expect(subject).to include(an_object_having_attributes(name: :filesystem_readable, value: 0)) + expect(subject).to include(an_object_having_attributes(name: :filesystem_writable, value: 0)) + + expect(subject).to include(an_object_having_attributes(name: :filesystem_access_latency, value: be >= 0)) + expect(subject).to include(an_object_having_attributes(name: :filesystem_read_latency, value: be >= 0)) + expect(subject).to include(an_object_having_attributes(name: :filesystem_write_latency, value: be >= 0)) end end end context 'when popen always finds required binaries' do before do - allow(Gitlab::Popen).to receive(:popen).and_wrap_original do |method, *args, &block| + allow(described_class).to receive(:exec_with_timeout).and_wrap_original do |method, *args, &block| begin method.call(*args, &block) - rescue RuntimeError + rescue RuntimeError, Errno::ENOENT raise 'expected not to happen' end end + + stub_const('Gitlab::HealthChecks::FsShardsCheck::COMMAND_TIMEOUT', '10') end it_behaves_like 'filesystem checks' diff --git a/spec/lib/gitlab/import_export/members_mapper_spec.rb b/spec/lib/gitlab/import_export/members_mapper_spec.rb index b9d4e59e770353715ae1cb5dd869e8d15358f304..3e0291c9ae9c288c86a8531661b14606e00bba1b 100644 --- a/spec/lib/gitlab/import_export/members_mapper_spec.rb +++ b/spec/lib/gitlab/import_export/members_mapper_spec.rb @@ -2,9 +2,9 @@ require 'spec_helper' describe Gitlab::ImportExport::MembersMapper, services: true do describe 'map members' do - let(:user) { create(:admin, authorized_projects_populated: true) } + let(:user) { create(:admin) } let(:project) { create(:empty_project, :public, name: 'searchable_project') } - let(:user2) { create(:user, authorized_projects_populated: true) } + let(:user2) { create(:user) } let(:exported_user_id) { 99 } let(:exported_members) do [{ @@ -74,7 +74,7 @@ describe Gitlab::ImportExport::MembersMapper, services: true do end context 'user is not an admin' do - let(:user) { create(:user, authorized_projects_populated: true) } + let(:user) { create(:user) } it 'does not map a project member' do expect(members_mapper.map[exported_user_id]).to eq(user.id) @@ -94,7 +94,7 @@ describe Gitlab::ImportExport::MembersMapper, services: true do end context 'importer same as group member' do - let(:user2) { create(:admin, authorized_projects_populated: true) } + let(:user2) { create(:admin) } let(:group) { create(:group) } let(:project) { create(:empty_project, :public, name: 'searchable_project', namespace: group) } let(:members_mapper) do diff --git a/spec/lib/gitlab/o_auth/provider_spec.rb b/spec/lib/gitlab/o_auth/provider_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1e2a1f8c0393ef144f9501c898bdd8d10033726f --- /dev/null +++ b/spec/lib/gitlab/o_auth/provider_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe Gitlab::OAuth::Provider, lib: true do + describe '#config_for' do + context 'for an LDAP provider' do + context 'when the provider exists' do + it 'returns the config' do + expect(described_class.config_for('ldapmain')).to be_a(Hash) + end + end + + context 'when the provider does not exist' do + it 'returns nil' do + expect(described_class.config_for('ldapfoo')).to be_nil + end + end + end + + context 'for an OmniAuth provider' do + before do + provider = OpenStruct.new( + name: 'google', + app_id: 'asd123', + app_secret: 'asd123' + ) + allow(Gitlab.config.omniauth).to receive(:providers).and_return([provider]) + end + + context 'when the provider exists' do + it 'returns the config' do + expect(described_class.config_for('google')).to be_a(OpenStruct) + end + end + + context 'when the provider does not exist' do + it 'returns nil' do + expect(described_class.config_for('foo')).to be_nil + end + end + end + end +end diff --git a/spec/lib/gitlab/project_authorizations_spec.rb b/spec/lib/gitlab/project_authorizations_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..67321f43710bef3b1a094a5f61c4ccabca516cfc --- /dev/null +++ b/spec/lib/gitlab/project_authorizations_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' + +describe Gitlab::ProjectAuthorizations do + let(:group) { create(:group) } + let!(:owned_project) { create(:empty_project) } + let!(:other_project) { create(:empty_project) } + let!(:group_project) { create(:empty_project, namespace: group) } + + let(:user) { owned_project.namespace.owner } + + def map_access_levels(rows) + rows.each_with_object({}) do |row, hash| + hash[row.project_id] = row.access_level + end + end + + before do + other_project.team << [user, :reporter] + group.add_developer(user) + end + + let(:authorizations) do + klass = if Group.supports_nested_groups? + Gitlab::ProjectAuthorizations::WithNestedGroups + else + Gitlab::ProjectAuthorizations::WithoutNestedGroups + end + + klass.new(user).calculate + end + + it 'returns the correct number of authorizations' do + expect(authorizations.length).to eq(3) + end + + it 'includes the correct projects' do + expect(authorizations.pluck(:project_id)). + to include(owned_project.id, other_project.id, group_project.id) + end + + it 'includes the correct access levels' do + mapping = map_access_levels(authorizations) + + expect(mapping[owned_project.id]).to eq(Gitlab::Access::MASTER) + expect(mapping[other_project.id]).to eq(Gitlab::Access::REPORTER) + expect(mapping[group_project.id]).to eq(Gitlab::Access::DEVELOPER) + end + + if Group.supports_nested_groups? + context 'with nested groups' do + let!(:nested_group) { create(:group, parent: group) } + let!(:nested_project) { create(:empty_project, namespace: nested_group) } + + it 'includes nested groups' do + expect(authorizations.pluck(:project_id)).to include(nested_project.id) + end + + it 'inherits access levels when the user is not a member of a nested group' do + mapping = map_access_levels(authorizations) + + expect(mapping[nested_project.id]).to eq(Gitlab::Access::DEVELOPER) + end + + it 'uses the greatest access level when a user is a member of a nested group' do + nested_group.add_master(user) + + mapping = map_access_levels(authorizations) + + expect(mapping[nested_project.id]).to eq(Gitlab::Access::MASTER) + end + end + end +end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 1b8690ba613820f8b727a7ae551ac5103fa975ed..3d22784909d3a07285a27f6ba802ae977df9e4b8 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -123,8 +123,8 @@ describe Gitlab::ProjectSearchResults, lib: true do context 'when wiki is internal' do let(:project) { create(:project, :public, :wiki_private) } - it 'finds wiki blobs for members' do - project.add_reporter(user) + it 'finds wiki blobs for guest' do + project.add_guest(user) is_expected.not_to be_empty end diff --git a/spec/lib/gitlab/sql/recursive_cte_spec.rb b/spec/lib/gitlab/sql/recursive_cte_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..25146860615091a988b61e42f99b6e2a99bb8461 --- /dev/null +++ b/spec/lib/gitlab/sql/recursive_cte_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe Gitlab::SQL::RecursiveCTE, :postgresql do + let(:cte) { described_class.new(:cte_name) } + + describe '#to_arel' do + it 'generates an Arel relation for the CTE body' do + rel1 = User.where(id: 1) + rel2 = User.where(id: 2) + + cte << rel1 + cte << rel2 + + sql = cte.to_arel.to_sql + name = ActiveRecord::Base.connection.quote_table_name(:cte_name) + + sql1, sql2 = ActiveRecord::Base.connection.unprepared_statement do + [rel1.except(:order).to_sql, rel2.except(:order).to_sql] + end + + expect(sql).to eq("#{name} AS (#{sql1}\nUNION\n#{sql2})") + end + end + + describe '#alias_to' do + it 'returns an alias for the CTE' do + table = Arel::Table.new(:kittens) + + source_name = ActiveRecord::Base.connection.quote_table_name(:cte_name) + alias_name = ActiveRecord::Base.connection.quote_table_name(:kittens) + + expect(cte.alias_to(table).to_sql).to eq("#{source_name} AS #{alias_name}") + end + end + + describe '#apply_to' do + it 'applies a CTE to an ActiveRecord::Relation' do + user = create(:user) + cte = described_class.new(:cte_name) + + cte << User.where(id: user.id) + + relation = cte.apply_to(User.all) + + expect(relation.to_sql).to match(/WITH RECURSIVE.+cte_name/) + expect(relation.to_a).to eq(User.where(id: user.id).to_a) + end + end +end diff --git a/spec/lib/gitlab/url_sanitizer_spec.rb b/spec/lib/gitlab/url_sanitizer_spec.rb index fc144a2556a4566a20ecb0d65a30693a2b30a2b2..6bce724a3f61db6a270008967ad2f2c8a82b73ba 100644 --- a/spec/lib/gitlab/url_sanitizer_spec.rb +++ b/spec/lib/gitlab/url_sanitizer_spec.rb @@ -62,11 +62,6 @@ describe Gitlab::UrlSanitizer, lib: true do end end - describe '.http_credentials_for_user' do - it { expect(described_class.http_credentials_for_user(user)).to eq({ user: 'john.doe' }) } - it { expect(described_class.http_credentials_for_user('foo')).to eq({}) } - end - describe '#sanitized_url' do it { expect(url_sanitizer.sanitized_url).to eq("https://github.com/me/project.git") } end @@ -76,7 +71,7 @@ describe Gitlab::UrlSanitizer, lib: true do context 'when user is given to #initialize' do let(:url_sanitizer) do - described_class.new("https://github.com/me/project.git", credentials: described_class.http_credentials_for_user(user)) + described_class.new("https://github.com/me/project.git", credentials: { user: user.username }) end it { expect(url_sanitizer.credentials).to eq({ user: 'john.doe' }) } @@ -94,7 +89,7 @@ describe Gitlab::UrlSanitizer, lib: true do context 'when user is given to #initialize' do let(:url_sanitizer) do - described_class.new("https://github.com/me/project.git", credentials: described_class.http_credentials_for_user(user)) + described_class.new("https://github.com/me/project.git", credentials: { user: user.username }) end it { expect(url_sanitizer.full_url).to eq("https://john.doe@github.com/me/project.git") } diff --git a/spec/migrations/fill_authorized_projects_spec.rb b/spec/migrations/fill_authorized_projects_spec.rb deleted file mode 100644 index 99dc41958188573a1cb15b3e99acecf667983456..0000000000000000000000000000000000000000 --- a/spec/migrations/fill_authorized_projects_spec.rb +++ /dev/null @@ -1,18 +0,0 @@ -require 'spec_helper' -require Rails.root.join('db', 'post_migrate', '20170106142508_fill_authorized_projects.rb') - -describe FillAuthorizedProjects do - describe '#up' do - it 'schedules the jobs in batches' do - user1 = create(:user) - user2 = create(:user) - - expect(Sidekiq::Client).to receive(:push_bulk).with( - 'class' => 'AuthorizedProjectsWorker', - 'args' => [[user1.id], [user2.id]] - ) - - described_class.new.up - end - end -end diff --git a/spec/migrations/turn_nested_groups_into_regular_groups_for_mysql_spec.rb b/spec/migrations/turn_nested_groups_into_regular_groups_for_mysql_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..175bf1876b2c207f7a598650481647047ff3dbed --- /dev/null +++ b/spec/migrations/turn_nested_groups_into_regular_groups_for_mysql_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' +require Rails.root.join('db', 'migrate', '20170503140202_turn_nested_groups_into_regular_groups_for_mysql.rb') + +describe TurnNestedGroupsIntoRegularGroupsForMysql do + let!(:parent_group) { create(:group) } + let!(:child_group) { create(:group, parent: parent_group) } + let!(:project) { create(:project, :empty_repo, namespace: child_group) } + let!(:member) { create(:user) } + let(:migration) { described_class.new } + + before do + parent_group.add_developer(member) + + allow(migration).to receive(:run_migration?).and_return(true) + allow(migration).to receive(:verbose).and_return(false) + end + + describe '#up' do + let(:updated_project) do + # path_with_namespace is memoized in an instance variable so we retrieve a + # new row here to work around that. + Project.find(project.id) + end + + before do + migration.up + end + + it 'unsets the parent_id column' do + expect(Namespace.where('parent_id IS NOT NULL').any?).to eq(false) + end + + it 'adds members of parent groups as members to the migrated group' do + is_member = child_group.members. + where(user_id: member, access_level: Gitlab::Access::DEVELOPER).any? + + expect(is_member).to eq(true) + end + + it 'update the path of the nested group' do + child_group.reload + + expect(child_group.path).to eq("#{parent_group.name}-#{child_group.name}") + end + + it 'renames projects of the nested group' do + expect(updated_project.path_with_namespace). + to eq("#{parent_group.name}-#{child_group.name}/#{updated_project.path}") + end + + it 'renames the repository of any projects' do + expect(updated_project.repository.path). + to end_with("#{parent_group.name}-#{child_group.name}/#{updated_project.path}.git") + + expect(File.directory?(updated_project.repository.path)).to eq(true) + end + + it 'creates a redirect route for renamed projects' do + exists = RedirectRoute. + where(source_type: 'Project', source_id: project.id). + any? + + expect(exists).to eq(true) + end + end +end diff --git a/spec/migrations/update_retried_for_ci_builds_spec.rb b/spec/migrations/update_retried_for_ci_build_spec.rb similarity index 100% rename from spec/migrations/update_retried_for_ci_builds_spec.rb rename to spec/migrations/update_retried_for_ci_build_spec.rb diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb index 822b98c5f6c9a40575d5d7f96b6c92e1df0f57af..b00e7a735712fb29396e7789e15e19d8bdd22651 100644 --- a/spec/models/ci/pipeline_schedule_spec.rb +++ b/spec/models/ci/pipeline_schedule_spec.rb @@ -25,6 +25,14 @@ describe Ci::PipelineSchedule, models: true do expect(pipeline_schedule).not_to be_valid end + + context 'when active is false' do + it 'does not allow nullified ref' do + pipeline_schedule = build(:ci_pipeline_schedule, :inactive, ref: nil) + + expect(pipeline_schedule).not_to be_valid + end + end end describe '#set_next_run_at' do diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb index 49a4132f7638cf613f032dbc5b8ece87e93406be..0e10d91836d8964f49c573fac729c82944f93084 100644 --- a/spec/models/concerns/routable_spec.rb +++ b/spec/models/concerns/routable_spec.rb @@ -115,123 +115,6 @@ describe Group, 'Routable' do end end - describe '.member_descendants' do - let!(:user) { create(:user) } - let!(:nested_group) { create(:group, parent: group) } - - before { group.add_owner(user) } - subject { described_class.member_descendants(user.id) } - - it { is_expected.to eq([nested_group]) } - end - - describe '.member_self_and_descendants' do - let!(:user) { create(:user) } - let!(:nested_group) { create(:group, parent: group) } - - before { group.add_owner(user) } - subject { described_class.member_self_and_descendants(user.id) } - - it { is_expected.to match_array [group, nested_group] } - end - - describe '.member_hierarchy' do - # foo/bar would also match foo/barbaz instead of just foo/bar and foo/bar/baz - let!(:user) { create(:user) } - - # group - # _______ (foo) _______ - # | | - # | | - # nested_group_1 nested_group_2 - # (bar) (barbaz) - # | | - # | | - # nested_group_1_1 nested_group_2_1 - # (baz) (baz) - # - let!(:nested_group_1) { create :group, parent: group, name: 'bar' } - let!(:nested_group_1_1) { create :group, parent: nested_group_1, name: 'baz' } - let!(:nested_group_2) { create :group, parent: group, name: 'barbaz' } - let!(:nested_group_2_1) { create :group, parent: nested_group_2, name: 'baz' } - - context 'user is not a member of any group' do - subject { described_class.member_hierarchy(user.id) } - - it 'returns an empty array' do - is_expected.to eq [] - end - end - - context 'user is member of all groups' do - before do - group.add_owner(user) - nested_group_1.add_owner(user) - nested_group_1_1.add_owner(user) - nested_group_2.add_owner(user) - nested_group_2_1.add_owner(user) - end - subject { described_class.member_hierarchy(user.id) } - - it 'returns all groups' do - is_expected.to match_array [ - group, - nested_group_1, nested_group_1_1, - nested_group_2, nested_group_2_1 - ] - end - end - - context 'user is member of the top group' do - before { group.add_owner(user) } - subject { described_class.member_hierarchy(user.id) } - - it 'returns all groups' do - is_expected.to match_array [ - group, - nested_group_1, nested_group_1_1, - nested_group_2, nested_group_2_1 - ] - end - end - - context 'user is member of the first child (internal node), branch 1' do - before { nested_group_1.add_owner(user) } - subject { described_class.member_hierarchy(user.id) } - - it 'returns the groups in the hierarchy' do - is_expected.to match_array [ - group, - nested_group_1, nested_group_1_1 - ] - end - end - - context 'user is member of the first child (internal node), branch 2' do - before { nested_group_2.add_owner(user) } - subject { described_class.member_hierarchy(user.id) } - - it 'returns the groups in the hierarchy' do - is_expected.to match_array [ - group, - nested_group_2, nested_group_2_1 - ] - end - end - - context 'user is member of the last child (leaf node)' do - before { nested_group_1_1.add_owner(user) } - subject { described_class.member_hierarchy(user.id) } - - it 'returns the groups in the hierarchy' do - is_expected.to match_array [ - group, - nested_group_1, nested_group_1_1 - ] - end - end - end - describe '#full_path' do let(:group) { create(:group) } let(:nested_group) { create(:group, parent: group) } diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 6ca1eb0374d32601357c7c76e93fb2bf26a09e1f..316bf153660ffa815907f544d4c973e44eed6bc9 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -340,7 +340,7 @@ describe Group, models: true do it { expect(subject.parent).to be_kind_of(Group) } end - describe '#members_with_parents' do + describe '#members_with_parents', :nested_groups do let!(:group) { create(:group, :nested) } let!(:master) { group.parent.add_user(create(:user), GroupMember::MASTER) } let!(:developer) { group.add_user(create(:user), GroupMember::DEVELOPER) } diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index 87ea2e706807d39431b2d90ecc8c4129feb43bcf..cf9c701e8c580015cd0ae1a3b04e586b21f1ca48 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -22,16 +22,15 @@ describe ProjectMember, models: true do end describe '.add_user' do - context 'when called with the project owner' do - it 'adds the user as a member' do - project = create(:empty_project) + it 'adds the user as a member' do + user = create(:user) + project = create(:empty_project) - expect(project.users).not_to include(project.owner) + expect(project.users).not_to include(user) - described_class.add_user(project, project.owner, :master, current_user: project.owner) + described_class.add_user(project, user, :master, current_user: project.owner) - expect(project.users.reload).to include(project.owner) - end + expect(project.users.reload).to include(user) end end diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index e3e8e6d571cb9bfd438e5d6e6e0bbdf1d768c652..aa1ce89ffd7f713ad7d7c43f20b7b33fe8df056d 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -249,4 +249,17 @@ describe Milestone, models: true do expect(milestone.to_reference(another_project)).to eq "sample-project%1" end end + + describe '#participants' do + let(:project) { build(:empty_project, name: 'sample-project') } + let(:milestone) { build(:milestone, iid: 1, project: project) } + + it 'returns participants without duplicates' do + user = create :user + create :issue, project: project, milestone: milestone, assignees: [user] + create :issue, project: project, milestone: milestone, assignees: [user] + + expect(milestone.participants).to eq [user] + end + end end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index ff5e7c350aae4c36cebe7fbc08a1c607775d9d83..0e74f1ab1bd104604b6ed9fb390c623a06d4a37a 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -287,21 +287,21 @@ describe Namespace, models: true do end end - describe '#ancestors' do + describe '#ancestors', :nested_groups do let(:group) { create(:group) } let(:nested_group) { create(:group, parent: group) } let(:deep_nested_group) { create(:group, parent: nested_group) } let(:very_deep_nested_group) { create(:group, parent: deep_nested_group) } it 'returns the correct ancestors' do - expect(very_deep_nested_group.ancestors).to eq([group, nested_group, deep_nested_group]) - expect(deep_nested_group.ancestors).to eq([group, nested_group]) - expect(nested_group.ancestors).to eq([group]) + expect(very_deep_nested_group.ancestors).to include(group, nested_group, deep_nested_group) + expect(deep_nested_group.ancestors).to include(group, nested_group) + expect(nested_group.ancestors).to include(group) expect(group.ancestors).to eq([]) end end - describe '#descendants' do + describe '#descendants', :nested_groups do let!(:group) { create(:group, path: 'git_lab') } let!(:nested_group) { create(:group, parent: group) } let!(:deep_nested_group) { create(:group, parent: nested_group) } @@ -311,9 +311,9 @@ describe Namespace, models: true do it 'returns the correct descendants' do expect(very_deep_nested_group.descendants.to_a).to eq([]) - expect(deep_nested_group.descendants.to_a).to eq([very_deep_nested_group]) - expect(nested_group.descendants.to_a).to eq([deep_nested_group, very_deep_nested_group]) - expect(group.descendants.to_a).to eq([nested_group, deep_nested_group, very_deep_nested_group]) + expect(deep_nested_group.descendants.to_a).to include(very_deep_nested_group) + expect(nested_group.descendants.to_a).to include(deep_nested_group, very_deep_nested_group) + expect(group.descendants.to_a).to include(nested_group, deep_nested_group, very_deep_nested_group) end end diff --git a/spec/models/project_group_link_spec.rb b/spec/models/project_group_link_spec.rb index 9b711bfc007501e0c7ec34e14716f567fb1aa595..4161b9158b1ac6693a50e8a1d003ff51cb844485 100644 --- a/spec/models/project_group_link_spec.rb +++ b/spec/models/project_group_link_spec.rb @@ -23,7 +23,7 @@ describe ProjectGroupLink do expect(project_group_link).not_to be_valid end - it "doesn't allow a project to be shared with an ancestor of the group it is in" do + it "doesn't allow a project to be shared with an ancestor of the group it is in", :nested_groups do project_group_link.group = parent_group expect(project_group_link).not_to be_valid diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 4bca0229e7aee3eab66dc1cc2e36c760df3e18d3..1920b5bf42b8e769abd4ca8301d2c8ec3f59efa4 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -22,6 +22,42 @@ describe JiraService, models: true do it { is_expected.not_to validate_presence_of(:url) } end + + context 'validating urls' do + let(:service) do + described_class.new( + project: create(:empty_project), + active: true, + username: 'username', + password: 'test', + project_key: 'TEST', + jira_issue_transition_id: 24, + url: 'http://jira.test.com' + ) + end + + it 'is valid when all fields have required values' do + expect(service).to be_valid + end + + it 'is not valid when url is not a valid url' do + service.url = 'not valid' + + expect(service).not_to be_valid + end + + it 'is not valid when api url is not a valid url' do + service.api_url = 'not valid' + + expect(service).not_to be_valid + end + + it 'is valid when api url is a valid url' do + service.api_url = 'http://jira.test.com/api' + + expect(service).to be_valid + end + end end describe '#reference_pattern' do @@ -97,6 +133,7 @@ describe JiraService, models: true do allow(JIRA::Resource::Issue).to receive(:find).and_return(open_issue, closed_issue) allow_any_instance_of(JIRA::Resource::Issue).to receive(:key).and_return("JIRA-123") + allow(JIRA::Resource::Remotelink).to receive(:all).and_return([]) @jira_service.save @@ -187,22 +224,29 @@ describe JiraService, models: true do describe '#test_settings' do let(:jira_service) do described_class.new( + project: create(:project), url: 'http://jira.example.com', - username: 'gitlab_jira_username', - password: 'gitlab_jira_password', + username: 'jira_username', + password: 'jira_password', project_key: 'GitLabProject' ) end - let(:project_url) { 'http://gitlab_jira_username:gitlab_jira_password@jira.example.com/rest/api/2/project/GitLabProject' } - before do + def test_settings(api_url) + project_url = "http://jira_username:jira_password@#{api_url}/rest/api/2/project/GitLabProject" + WebMock.stub_request(:get, project_url) - end - it 'tries to get JIRA project' do jira_service.test_settings + end + + it 'tries to get JIRA project with URL when API URL not set' do + test_settings('jira.example.com') + end - expect(WebMock).to have_requested(:get, project_url) + it 'tries to get JIRA project with API URL if set' do + jira_service.update(api_url: 'http://jira.api.com') + test_settings('jira.api.com') end end @@ -214,34 +258,75 @@ describe JiraService, models: true do @jira_service = JiraService.create!( project: project, properties: { - url: 'http://jira.example.com/rest/api/2', + url: 'http://jira.example.com/web', username: 'mic', password: "password" } ) end - it "reset password if url changed" do - @jira_service.url = 'http://jira_edited.example.com/rest/api/2' - @jira_service.save - expect(@jira_service.password).to be_nil + context 'when only web url present' do + it 'reset password if url changed' do + @jira_service.url = 'http://jira_edited.example.com/rest/api/2' + @jira_service.save + + expect(@jira_service.password).to be_nil + end + + it 'reset password if url not changed but api url added' do + @jira_service.api_url = 'http://jira_edited.example.com/rest/api/2' + @jira_service.save + + expect(@jira_service.password).to be_nil + end end - it "does not reset password if username changed" do - @jira_service.username = "some_name" + context 'when both web and api url present' do + before do + @jira_service.api_url = 'http://jira.example.com/rest/api/2' + @jira_service.password = 'password' + + @jira_service.save + end + it 'reset password if api url changed' do + @jira_service.api_url = 'http://jira_edited.example.com/rest/api/2' + @jira_service.save + + expect(@jira_service.password).to be_nil + end + + it 'does not reset password if url changed' do + @jira_service.url = 'http://jira_edited.example.com/rweb' + @jira_service.save + + expect(@jira_service.password).to eq("password") + end + + it 'reset password if api url set to ""' do + @jira_service.api_url = '' + @jira_service.save + + expect(@jira_service.password).to be_nil + end + end + + it 'does not reset password if username changed' do + @jira_service.username = 'some_name' @jira_service.save - expect(@jira_service.password).to eq("password") + + expect(@jira_service.password).to eq('password') end - it "does not reset password if new url is set together with password, even if it's the same password" do + it 'does not reset password if new url is set together with password, even if it\'s the same password' do @jira_service.url = 'http://jira_edited.example.com/rest/api/2' @jira_service.password = 'password' @jira_service.save - expect(@jira_service.password).to eq("password") - expect(@jira_service.url).to eq("http://jira_edited.example.com/rest/api/2") + + expect(@jira_service.password).to eq('password') + expect(@jira_service.url).to eq('http://jira_edited.example.com/rest/api/2') end - it "resets password if url changed, even if setter called multiple times" do + it 'resets password if url changed, even if setter called multiple times' do @jira_service.url = 'http://jira1.example.com/rest/api/2' @jira_service.url = 'http://jira1.example.com/rest/api/2' @jira_service.save @@ -249,7 +334,7 @@ describe JiraService, models: true do end end - context "when no password was previously set" do + context 'when no password was previously set' do before do @jira_service = JiraService.create( project: project, @@ -260,26 +345,16 @@ describe JiraService, models: true do ) end - it "saves password if new url is set together with password" do + it 'saves password if new url is set together with password' do @jira_service.url = 'http://jira_edited.example.com/rest/api/2' @jira_service.password = 'password' @jira_service.save - expect(@jira_service.password).to eq("password") - expect(@jira_service.url).to eq("http://jira_edited.example.com/rest/api/2") + expect(@jira_service.password).to eq('password') + expect(@jira_service.url).to eq('http://jira_edited.example.com/rest/api/2') end end end - describe "Validations" do - context "active" do - before do - subject.active = true - end - - it { is_expected.to validate_presence_of :url } - end - end - describe 'description and title' do let(:project) { create(:empty_project) } @@ -321,9 +396,10 @@ describe JiraService, models: true do context 'when gitlab.yml was initialized' do before do settings = { - "jira" => { - "title" => "Jira", - "url" => "http://jira.sample/projects/project_a" + 'jira' => { + 'title' => 'Jira', + 'url' => 'http://jira.sample/projects/project_a', + 'api_url' => 'http://jira.sample/api' } } allow(Gitlab.config).to receive(:issues_tracker).and_return(settings) @@ -335,8 +411,9 @@ describe JiraService, models: true do end it 'is prepopulated with the settings' do - expect(@service.properties["title"]).to eq('Jira') - expect(@service.properties["url"]).to eq('http://jira.sample/projects/project_a') + expect(@service.properties['title']).to eq('Jira') + expect(@service.properties['url']).to eq('http://jira.sample/projects/project_a') + expect(@service.properties['api_url']).to eq('http://jira.sample/api') end end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index f2b4e9070b41592dc2dc2e30b90d82a26ecb187b..2ef3d5654d59f22a8ea41f1ad8ae603a1493223c 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1431,6 +1431,31 @@ describe Project, models: true do end end + describe 'Project import job' do + let(:project) { create(:empty_project) } + let(:mirror) { false } + + before do + allow_any_instance_of(Gitlab::Shell).to receive(:import_repository) + .with(project.repository_storage_path, project.path_with_namespace, project.import_url) + .and_return(true) + + allow(project).to receive(:repository_exists?).and_return(true) + + expect_any_instance_of(Repository).to receive(:after_import) + .and_call_original + end + + it 'imports a project' do + expect_any_instance_of(RepositoryImportWorker).to receive(:perform).and_call_original + + project.import_start + project.add_import_job + + expect(project.reload.import_status).to eq('finished') + end + end + describe '#latest_successful_builds_for' do def create_pipeline(status = 'success') create(:ci_pipeline, project: project, @@ -1884,19 +1909,9 @@ describe Project, models: true do describe '#http_url_to_repo' do let(:project) { create :empty_project } - context 'when no user is given' do - it 'returns the url to the repo without a username' do - expect(project.http_url_to_repo).to eq("#{project.web_url}.git") - expect(project.http_url_to_repo).not_to include('@') - end - end - - context 'when user is given' do - it 'returns the url to the repo with the username' do - user = build_stubbed(:user) - - expect(project.http_url_to_repo(user)).to start_with("http://#{user.username}@") - end + it 'returns the url to the repo without a username' do + expect(project.http_url_to_repo).to eq("#{project.web_url}.git") + expect(project.http_url_to_repo).not_to include('@') end end diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb index 942eeab251daad845926c655ec84f3c064461d6c..fb2d5f600098e9167bebe9239d85a2eb19f7b5c6 100644 --- a/spec/models/project_team_spec.rb +++ b/spec/models/project_team_spec.rb @@ -81,7 +81,7 @@ describe ProjectTeam, models: true do user = create(:user) project.add_guest(user) - expect(project.team.members).to contain_exactly(user) + expect(project.team.members).to contain_exactly(user, project.owner) end it 'returns project members of a specified level' do @@ -100,7 +100,8 @@ describe ProjectTeam, models: true do group_access: Gitlab::Access::GUEST ) - expect(project.team.members).to contain_exactly(group_member.user) + expect(project.team.members). + to contain_exactly(group_member.user, project.owner) end it 'returns invited members of a group of a specified level' do @@ -137,7 +138,10 @@ describe ProjectTeam, models: true do describe '#find_member' do context 'personal project' do - let(:project) { create(:empty_project, :public, :access_requestable) } + let(:project) do + create(:empty_project, :public, :access_requestable) + end + let(:requester) { create(:user) } before do @@ -200,7 +204,9 @@ describe ProjectTeam, models: true do let(:requester) { create(:user) } context 'personal project' do - let(:project) { create(:empty_project, :public, :access_requestable) } + let(:project) do + create(:empty_project, :public, :access_requestable) + end context 'when project is not shared with group' do before do @@ -244,7 +250,9 @@ describe ProjectTeam, models: true do context 'group project' do let(:group) { create(:group, :access_requestable) } - let!(:project) { create(:empty_project, group: group) } + let!(:project) do + create(:empty_project, group: group) + end before do group.add_master(master) @@ -265,8 +273,15 @@ describe ProjectTeam, models: true do let(:group) { create(:group) } let(:developer) { create(:user) } let(:master) { create(:user) } - let(:personal_project) { create(:empty_project, namespace: developer.namespace) } - let(:group_project) { create(:empty_project, namespace: group) } + + let(:personal_project) do + create(:empty_project, namespace: developer.namespace) + end + + let(:group_project) do + create(:empty_project, namespace: group) + end + let(:members_project) { create(:empty_project) } let(:shared_project) { create(:empty_project) } diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index 969e9f7a130de9a74b9ed550e3867a27b2c46590..224067f58ddf77fa76c20e01cd23a16e4a01961c 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -37,21 +37,11 @@ describe ProjectWiki, models: true do describe "#http_url_to_repo" do let(:project) { create :empty_project } - context 'when no user is given' do - it 'returns the url to the repo without a username' do - expected_url = "#{Gitlab.config.gitlab.url}/#{subject.path_with_namespace}.git" + it 'returns the full http url to the repo' do + expected_url = "#{Gitlab.config.gitlab.url}/#{subject.path_with_namespace}.git" - expect(project_wiki.http_url_to_repo).to eq(expected_url) - expect(project_wiki.http_url_to_repo).not_to include('@') - end - end - - context 'when user is given' do - it 'returns the url to the repo with the username' do - user = build_stubbed(:user) - - expect(project_wiki.http_url_to_repo(user)).to start_with("http://#{user.username}@") - end + expect(project_wiki.http_url_to_repo).to eq(expected_url) + expect(project_wiki.http_url_to_repo).not_to include('@') end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index aabdac4bb7562bb43300be768f2c14431c3ef602..9edf34b05ad3a1d9ed9670a4edfab43b785dfaaf 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -627,16 +627,6 @@ describe User, models: true do it { expect(User.without_projects).to include user_without_project2 } end - describe '.not_in_project' do - before do - User.delete_all - @user = create :user - @project = create(:empty_project) - end - - it { expect(User.not_in_project(@project)).to include(@user, @project.owner) } - end - describe 'user creation' do describe 'normal user' do let(:user) { create(:user, name: 'John Smith') } @@ -1561,48 +1551,103 @@ describe User, models: true do end end - describe '#nested_groups' do + describe '#all_expanded_groups' do + # foo/bar would also match foo/barbaz instead of just foo/bar and foo/bar/baz let!(:user) { create(:user) } - let!(:group) { create(:group) } - let!(:nested_group) { create(:group, parent: group) } - before do - group.add_owner(user) + # group + # _______ (foo) _______ + # | | + # | | + # nested_group_1 nested_group_2 + # (bar) (barbaz) + # | | + # | | + # nested_group_1_1 nested_group_2_1 + # (baz) (baz) + # + let!(:group) { create :group } + let!(:nested_group_1) { create :group, parent: group, name: 'bar' } + let!(:nested_group_1_1) { create :group, parent: nested_group_1, name: 'baz' } + let!(:nested_group_2) { create :group, parent: group, name: 'barbaz' } + let!(:nested_group_2_1) { create :group, parent: nested_group_2, name: 'baz' } - # Add more data to ensure method does not include wrong groups - create(:group).add_owner(create(:user)) + subject { user.all_expanded_groups } + + context 'user is not a member of any group' do + it 'returns an empty array' do + is_expected.to eq([]) + end end - it { expect(user.nested_groups).to eq([nested_group]) } - end + context 'user is member of all groups' do + before do + group.add_owner(user) + nested_group_1.add_owner(user) + nested_group_1_1.add_owner(user) + nested_group_2.add_owner(user) + nested_group_2_1.add_owner(user) + end - describe '#all_expanded_groups' do - let!(:user) { create(:user) } - let!(:group) { create(:group) } - let!(:nested_group_1) { create(:group, parent: group) } - let!(:nested_group_2) { create(:group, parent: group) } + it 'returns all groups' do + is_expected.to match_array [ + group, + nested_group_1, nested_group_1_1, + nested_group_2, nested_group_2_1 + ] + end + end - before { nested_group_1.add_owner(user) } + context 'user is member of the top group' do + before { group.add_owner(user) } - it { expect(user.all_expanded_groups).to match_array [group, nested_group_1] } - end + if Group.supports_nested_groups? + it 'returns all groups' do + is_expected.to match_array [ + group, + nested_group_1, nested_group_1_1, + nested_group_2, nested_group_2_1 + ] + end + else + it 'returns the top-level groups' do + is_expected.to match_array [group] + end + end + end - describe '#nested_groups_projects' do - let!(:user) { create(:user) } - let!(:group) { create(:group) } - let!(:nested_group) { create(:group, parent: group) } - let!(:project) { create(:empty_project, namespace: group) } - let!(:nested_project) { create(:empty_project, namespace: nested_group) } + context 'user is member of the first child (internal node), branch 1', :nested_groups do + before { nested_group_1.add_owner(user) } - before do - group.add_owner(user) + it 'returns the groups in the hierarchy' do + is_expected.to match_array [ + group, + nested_group_1, nested_group_1_1 + ] + end + end + + context 'user is member of the first child (internal node), branch 2', :nested_groups do + before { nested_group_2.add_owner(user) } - # Add more data to ensure method does not include wrong projects - other_project = create(:empty_project, namespace: create(:group, :nested)) - other_project.add_developer(create(:user)) + it 'returns the groups in the hierarchy' do + is_expected.to match_array [ + group, + nested_group_2, nested_group_2_1 + ] + end end - it { expect(user.nested_groups_projects).to eq([nested_project]) } + context 'user is member of the last child (leaf node)', :nested_groups do + before { nested_group_1_1.add_owner(user) } + + it 'returns the groups in the hierarchy' do + is_expected.to match_array [ + group, + nested_group_1, nested_group_1_1 + ] + end + end end describe '#refresh_authorized_projects', redis: true do @@ -1622,10 +1667,6 @@ describe User, models: true do expect(user.project_authorizations.count).to eq(2) end - it 'sets the authorized_projects_populated column' do - expect(user.authorized_projects_populated).to eq(true) - end - it 'stores the correct access levels' do expect(user.project_authorizations.where(access_level: Gitlab::Access::GUEST).exists?).to eq(true) expect(user.project_authorizations.where(access_level: Gitlab::Access::REPORTER).exists?).to eq(true) @@ -1735,7 +1776,7 @@ describe User, models: true do end end - context 'with 2FA requirement on nested parent group' do + context 'with 2FA requirement on nested parent group', :nested_groups do let!(:group1) { create :group, require_two_factor_authentication: true } let!(:group1a) { create :group, require_two_factor_authentication: false, parent: group1 } @@ -1750,7 +1791,7 @@ describe User, models: true do end end - context 'with 2FA requirement on nested child group' do + context 'with 2FA requirement on nested child group', :nested_groups do let!(:group1) { create :group, require_two_factor_authentication: false } let!(:group1a) { create :group, require_two_factor_authentication: true, parent: group1 } diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index 2077c14ff7a3c15c1a680f1b242f95f12e3a375d..4c37a553227862bfebd19a26fcf0a8a220375c2b 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -107,7 +107,7 @@ describe GroupPolicy, models: true do end end - describe 'private nested group inherit permissions' do + describe 'private nested group inherit permissions', :nested_groups do let(:nested_group) { create(:group, :private, parent: group) } subject { described_class.abilities(current_user, nested_group).to_set } diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index 0b0e4c2b1126bcf54ce6933d872ba3f07ac9b613..b84361d3abd519717ea8d1699d6ef6c581929239 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -5,7 +5,6 @@ describe API::Commits do let(:user) { create(:user) } let(:user2) { create(:user) } let!(:project) { create(:project, :repository, creator: user, namespace: user.namespace) } - let!(:master) { create(:project_member, :master, user: user, project: project) } let!(:guest) { create(:project_member, :guest, user: user2, project: project) } let!(:note) { create(:note_on_commit, author: user, project: project, commit_id: project.repository.commit.id, note: 'a comment on a commit') } let!(:another_note) { create(:note_on_commit, author: user, project: project, commit_id: project.repository.commit.id, note: 'another comment on a commit') } diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index 90b36374ded161c07219caba5f0d4ff39a165705..bb53796cbd7c63214c82bae2e829b4c0217ac985 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -429,7 +429,7 @@ describe API::Groups do expect(json_response["request_access_enabled"]).to eq(group[:request_access_enabled]) end - it "creates a nested group" do + it "creates a nested group", :nested_groups do parent = create(:group) parent.add_owner(user3) group = attributes_for(:group, { parent_id: parent.id }) diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb index f9e5316b3de5c9bb0f78a4eef6c11c79c60da223..9e6957e99225f731910a9108d6bc7955542ef055 100644 --- a/spec/requests/api/pipelines_spec.rb +++ b/spec/requests/api/pipelines_spec.rb @@ -7,7 +7,7 @@ describe API::Pipelines do let!(:pipeline) do create(:ci_empty_pipeline, project: project, sha: project.commit.id, - ref: project.default_branch) + ref: project.default_branch, user: user) end before { project.team << [user, :master] } @@ -232,20 +232,26 @@ describe API::Pipelines do context 'when order_by and sort are specified' do context 'when order_by user_id' do - let!(:pipeline) { create_list(:ci_pipeline, 2, project: project, user: create(:user)) } + before do + 3.times do + create(:ci_pipeline, project: project, user: create(:user)) + end + end - it 'sorts as user_id: :asc' do - get api("/projects/#{project.id}/pipelines", user), order_by: 'user_id', sort: 'asc' + context 'when sort parameter is valid' do + it 'sorts as user_id: :desc' do + get api("/projects/#{project.id}/pipelines", user), order_by: 'user_id', sort: 'desc' - expect(response).to have_http_status(:ok) - expect(response).to include_pagination_headers - expect(json_response).not_to be_empty - pipeline.sort_by { |p| p.user.id }.tap do |sorted_pipeline| - json_response.each_with_index { |r, i| expect(r['id']).to eq(sorted_pipeline[i].id) } + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(json_response).not_to be_empty + + pipeline_ids = Ci::Pipeline.all.order(user_id: :desc).pluck(:id) + expect(json_response.map { |r| r['id'] }).to eq(pipeline_ids) end end - context 'when sort is invalid' do + context 'when sort parameter is invalid' do it 'returns bad_request' do get api("/projects/#{project.id}/pipelines", user), order_by: 'user_id', sort: 'invalid_sort' diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index d5c3b5b34adc606b8807c7d711acbfe89eeadcb3..f95a287a184afb11c792ff3bb260ac4457ec9341 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -11,8 +11,7 @@ describe API::Projects do let(:project) { create(:empty_project, creator_id: user.id, namespace: user.namespace) } let(:project2) { create(:empty_project, path: 'project2', creator_id: user.id, namespace: user.namespace) } let(:snippet) { create(:project_snippet, :public, author: user, project: project, title: 'example') } - let(:project_member) { create(:project_member, :master, user: user, project: project) } - let(:project_member2) { create(:project_member, :developer, user: user3, project: project) } + let(:project_member) { create(:project_member, :developer, user: user3, project: project) } let(:user4) { create(:user) } let(:project3) do create(:project, @@ -27,7 +26,7 @@ describe API::Projects do builds_enabled: false, snippets_enabled: false) end - let(:project_member3) do + let(:project_member2) do create(:project_member, user: user4, project: project3, @@ -210,7 +209,7 @@ describe API::Projects do let(:public_project) { create(:empty_project, :public) } before do - project_member2 + project_member user3.update_attributes(starred_projects: [project, project2, project3, public_project]) end @@ -784,19 +783,18 @@ describe API::Projects do describe 'GET /projects/:id/users' do shared_examples_for 'project users response' do it 'returns the project users' do - member = create(:user) - create(:project_member, :developer, user: member, project: project) - get api("/projects/#{project.id}/users", current_user) + user = project.namespace.owner + expect(response).to have_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.size).to eq(1) first_user = json_response.first - expect(first_user['username']).to eq(member.username) - expect(first_user['name']).to eq(member.name) + expect(first_user['username']).to eq(user.username) + expect(first_user['name']).to eq(user.name) expect(first_user.keys).to contain_exactly(*%w[name username id state avatar_url web_url]) end end @@ -1091,8 +1089,8 @@ describe API::Projects do before { user4 } before { project3 } before { project4 } - before { project_member3 } before { project_member2 } + before { project_member } it 'returns 400 when nothing sent' do project_param = {} @@ -1573,7 +1571,7 @@ describe API::Projects do context 'when authenticated as developer' do before do - project_member2 + project_member end it 'returns forbidden error' do diff --git a/spec/requests/api/v3/commits_spec.rb b/spec/requests/api/v3/commits_spec.rb index c2e8c3ae6f730675b522cd7a672e33663d080cb1..386f60065adfe0d0a29d8498293ce90d31b7d47a 100644 --- a/spec/requests/api/v3/commits_spec.rb +++ b/spec/requests/api/v3/commits_spec.rb @@ -5,7 +5,6 @@ describe API::V3::Commits do let(:user) { create(:user) } let(:user2) { create(:user) } let!(:project) { create(:project, :repository, creator: user, namespace: user.namespace) } - let!(:master) { create(:project_member, :master, user: user, project: project) } let!(:guest) { create(:project_member, :guest, user: user2, project: project) } let!(:note) { create(:note_on_commit, author: user, project: project, commit_id: project.repository.commit.id, note: 'a comment on a commit') } let!(:another_note) { create(:note_on_commit, author: user, project: project, commit_id: project.repository.commit.id, note: 'another comment on a commit') } diff --git a/spec/requests/api/v3/groups_spec.rb b/spec/requests/api/v3/groups_spec.rb index bc261b5e07ca7607ff025bc267439cb6b506393a..98e8c954909d6a29ba35aa8ca874624bbd6bcfca 100644 --- a/spec/requests/api/v3/groups_spec.rb +++ b/spec/requests/api/v3/groups_spec.rb @@ -421,7 +421,7 @@ describe API::V3::Groups do expect(json_response["request_access_enabled"]).to eq(group[:request_access_enabled]) end - it "creates a nested group" do + it "creates a nested group", :nested_groups do parent = create(:group) parent.add_owner(user3) group = attributes_for(:group, { parent_id: parent.id }) diff --git a/spec/requests/api/v3/projects_spec.rb b/spec/requests/api/v3/projects_spec.rb index dc7c3d125b1d15dcd22e4db7036a409d8735d142..bc591b2eb37238a8b4dee40f0af3fae760d541d9 100644 --- a/spec/requests/api/v3/projects_spec.rb +++ b/spec/requests/api/v3/projects_spec.rb @@ -10,8 +10,7 @@ describe API::V3::Projects do let(:project) { create(:empty_project, creator_id: user.id, namespace: user.namespace) } let(:project2) { create(:empty_project, path: 'project2', creator_id: user.id, namespace: user.namespace) } let(:snippet) { create(:project_snippet, :public, author: user, project: project, title: 'example') } - let(:project_member) { create(:project_member, :master, user: user, project: project) } - let(:project_member2) { create(:project_member, :developer, user: user3, project: project) } + let(:project_member) { create(:project_member, :developer, user: user3, project: project) } let(:user4) { create(:user) } let(:project3) do create(:project, @@ -25,7 +24,7 @@ describe API::V3::Projects do issues_enabled: false, wiki_enabled: false, snippets_enabled: false) end - let(:project_member3) do + let(:project_member2) do create(:project_member, user: user4, project: project3, @@ -286,7 +285,7 @@ describe API::V3::Projects do let(:public_project) { create(:empty_project, :public) } before do - project_member2 + project_member user3.update_attributes(starred_projects: [project, project2, project3, public_project]) end @@ -622,7 +621,6 @@ describe API::V3::Projects do context 'when authenticated' do before do project - project_member end it 'returns a project by id' do @@ -814,8 +812,7 @@ describe API::V3::Projects do describe 'GET /projects/:id/users' do shared_examples_for 'project users response' do it 'returns the project users' do - member = create(:user) - create(:project_member, :developer, user: member, project: project) + member = project.owner get v3_api("/projects/#{project.id}/users", current_user) @@ -1163,8 +1160,8 @@ describe API::V3::Projects do before { user4 } before { project3 } before { project4 } - before { project_member3 } before { project_member2 } + before { project_member } context 'when unauthenticated' do it 'returns authentication error' do diff --git a/spec/rubocop/cop/migration/update_column_in_batches_spec.rb b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..968dcd6232ef55f12ce8696b1d4c5a8c85ec902a --- /dev/null +++ b/spec/rubocop/cop/migration/update_column_in_batches_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' +require 'rubocop' +require 'rubocop/rspec/support' +require_relative '../../../../rubocop/cop/migration/update_column_in_batches' + +describe RuboCop::Cop::Migration::UpdateColumnInBatches do + let(:cop) { described_class.new } + let(:tmp_rails_root) { Rails.root.join('tmp', 'rails_root') } + let(:migration_code) do + <<-END + def up + update_column_in_batches(:projects, :name, "foo") do |table, query| + query.where(table[:name].eq(nil)) + end + end + END + end + + before do + allow(cop).to receive(:rails_root).and_return(tmp_rails_root) + end + after do + FileUtils.rm_rf(tmp_rails_root) + end + + context 'outside of a migration' do + it 'does not register any offenses' do + inspect_source(cop, migration_code) + + expect(cop.offenses).to be_empty + end + end + + let(:spec_filepath) { tmp_rails_root.join('spec', 'migrations', 'my_super_migration_spec.rb') } + + shared_context 'with a migration file' do + before do + FileUtils.mkdir_p(File.dirname(migration_filepath)) + @migration_file = File.new(migration_filepath, 'w+') + end + after do + @migration_file.close + end + end + + shared_examples 'a migration file with no spec file' do + include_context 'with a migration file' + + let(:relative_spec_filepath) { Pathname.new(spec_filepath).relative_path_from(tmp_rails_root) } + + it 'registers an offense when using update_column_in_batches' do + inspect_source(cop, migration_code, @migration_file) + + aggregate_failures do + expect(cop.offenses.size).to eq(1) + expect(cop.offenses.map(&:line)).to eq([2]) + expect(cop.offenses.first.message). + to include("`#{relative_spec_filepath}`") + end + end + end + + shared_examples 'a migration file with a spec file' do + include_context 'with a migration file' + + before do + FileUtils.mkdir_p(File.dirname(spec_filepath)) + @spec_file = File.new(spec_filepath, 'w+') + end + after do + @spec_file.close + end + + it 'does not register any offenses' do + inspect_source(cop, migration_code, @migration_file) + + expect(cop.offenses).to be_empty + end + end + + context 'in a migration' do + let(:migration_filepath) { tmp_rails_root.join('db', 'migrate', '20121220064453_my_super_migration.rb') } + + it_behaves_like 'a migration file with no spec file' + it_behaves_like 'a migration file with a spec file' + end + + context 'in a post migration' do + let(:migration_filepath) { tmp_rails_root.join('db', 'post_migrate', '20121220064453_my_super_migration.rb') } + + it_behaves_like 'a migration file with no spec file' + it_behaves_like 'a migration file with a spec file' + end +end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index b536103ed65ac0b2df236720677761f5d71804fa..030912b9f45d5178cd6a824b5b24165549ac2098 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -36,7 +36,7 @@ describe Ci::CreatePipelineService, services: true do expect(pipeline.builds.first).to be_kind_of(Ci::Build) end - context '#update_merge_requests_head_pipeline' do + context 'when merge requests already exist for this source branch' do it 'updates head pipeline of each merge request' do merge_request_1 = create(:merge_request, source_branch: 'master', target_branch: "branch_1", source_project: project) merge_request_2 = create(:merge_request, source_branch: 'master', target_branch: "branch_2", source_project: project) @@ -58,7 +58,7 @@ describe Ci::CreatePipelineService, services: true do end context 'when merge request target project is different from source project' do - let!(:target_project) { create(:empty_project) } + let!(:target_project) { create(:project) } let!(:forked_project_link) { create(:forked_project_link, forked_to_project: project, forked_from_project: target_project) } it 'updates head pipeline for merge request' do @@ -70,6 +70,17 @@ describe Ci::CreatePipelineService, services: true do expect(merge_request.reload.head_pipeline).to eq(head_pipeline) end end + + context 'when merge request head commit sha does not match pipeline sha' do + it 'does not update merge request head pipeline' do + merge_request = create(:merge_request, source_branch: 'master', target_branch: "branch_1", source_project: project) + allow_any_instance_of(MergeRequestDiff).to receive(:head_commit).and_return(double(id: 1234)) + + pipeline + + expect(merge_request.reload.head_pipeline).to be_nil + end + end end context 'auto-cancel enabled' do diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index ab06f45dbb90f4008d349186db1e8fb0cf798bef..9f5a8beac16c3fb67b42984f20aa03d4a183322d 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -436,6 +436,7 @@ describe GitPushService, services: true do author_name: commit_author.name, author_email: commit_author.email }) + allow(JIRA::Resource::Remotelink).to receive(:all).and_return([]) allow(project.repository).to receive_messages(commits_between: [closing_commit]) end diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 0a1f41719f7e4b5187ef7201e7305740ed553ee5..be0e829880e1a28886f2dca3ad66992ef204ceb1 100644 --- a/spec/services/issues/close_service_spec.rb +++ b/spec/services/issues/close_service_spec.rb @@ -41,6 +41,12 @@ describe Issues::CloseService, services: true do service.execute(issue) end + + it 'invalidates counter cache for assignees' do + expect_any_instance_of(User).to receive(:invalidate_issue_cache_counts) + + service.execute(issue) + end end describe '#close_issue' do diff --git a/spec/services/issues/reopen_service_spec.rb b/spec/services/issues/reopen_service_spec.rb index 93a8270fd16e477c97aab4842539e19eb76c032b..391ecad303a7e7722f7260c86cd218f76a9ea779 100644 --- a/spec/services/issues/reopen_service_spec.rb +++ b/spec/services/issues/reopen_service_spec.rb @@ -27,6 +27,13 @@ describe Issues::ReopenService, services: true do project.team << [user, :master] end + it 'invalidates counter cache for assignees' do + issue.assignees << user + expect_any_instance_of(User).to receive(:invalidate_issue_cache_counts) + + described_class.new(project, user).execute(issue) + end + context 'when issue is not confidential' do it 'executes issue hooks' do expect(project).to receive(:execute_hooks).with(an_instance_of(Hash), :issue_hooks) diff --git a/spec/services/members/authorized_destroy_service_spec.rb b/spec/services/members/authorized_destroy_service_spec.rb index 8a6732faa19a83bed2bca0b04e8f8bcde702a12b..f99b11f208cce32429a345423b862065e4f4436c 100644 --- a/spec/services/members/authorized_destroy_service_spec.rb +++ b/spec/services/members/authorized_destroy_service_spec.rb @@ -18,7 +18,7 @@ describe Members::AuthorizedDestroyService, services: true do member = create :project_member, :invited, project: project expect { described_class.new(member, member_user).execute } - .to change { Member.count }.from(2).to(1) + .to change { Member.count }.from(3).to(2) end it 'destroys invited group member' do diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb index d55a7657c0ea2acb160ae58aa1c448ee427ce660..154f30aac3b4ab2ed2878eebf885553e9cf9aa15 100644 --- a/spec/services/merge_requests/close_service_spec.rb +++ b/spec/services/merge_requests/close_service_spec.rb @@ -15,6 +15,8 @@ describe MergeRequests::CloseService, services: true do end describe '#execute' do + it_behaves_like 'cache counters invalidator' + context 'valid params' do let(:service) { described_class.new(project, user, {}) } diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index b70e9d534a4af3105738c6217a561561dbf8b730..2963f62cc7da2fc850c2a0dbc02efb11d2da3ec2 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -75,6 +75,37 @@ describe MergeRequests::CreateService, services: true do expect(Todo.where(attributes).count).to eq 1 end end + + context 'when head pipelines already exist for merge request source branch' do + let(:sha) { project.commit(opts[:source_branch]).id } + let!(:pipeline_1) { create(:ci_pipeline, project: project, ref: opts[:source_branch], project_id: project.id, sha: sha) } + let!(:pipeline_2) { create(:ci_pipeline, project: project, ref: opts[:source_branch], project_id: project.id, sha: sha) } + let!(:pipeline_3) { create(:ci_pipeline, project: project, ref: "other_branch", project_id: project.id) } + + before do + project.merge_requests. + where(source_branch: opts[:source_branch], target_branch: opts[:target_branch]). + destroy_all + end + + it 'sets head pipeline' do + merge_request = service.execute + + expect(merge_request.head_pipeline).to eq(pipeline_2) + expect(merge_request).to be_persisted + end + + context 'when merge request head commit sha does not match pipeline sha' do + it 'sets the head pipeline correctly' do + pipeline_2.update(sha: 1234) + + merge_request = service.execute + + expect(merge_request.head_pipeline).to eq(pipeline_1) + expect(merge_request).to be_persisted + end + end + end end it_behaves_like 'new issuable record that supports slash commands' do diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a20b32eda5f1aa90992463b1255a33d2293e4af7 --- /dev/null +++ b/spec/services/merge_requests/post_merge_service_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe MergeRequests::PostMergeService, services: true do + let(:user) { create(:user) } + let(:merge_request) { create(:merge_request, assignee: user) } + let(:project) { merge_request.project } + + before do + project.team << [user, :master] + end + + describe '#execute' do + it_behaves_like 'cache counters invalidator' + end +end diff --git a/spec/services/merge_requests/reopen_service_spec.rb b/spec/services/merge_requests/reopen_service_spec.rb index a99d4eac9bde5db6288496b3c8cc6597218029e0..b6d4db2f922e638e65b49a26966e7e0b5cd799cd 100644 --- a/spec/services/merge_requests/reopen_service_spec.rb +++ b/spec/services/merge_requests/reopen_service_spec.rb @@ -14,6 +14,8 @@ describe MergeRequests::ReopenService, services: true do end describe '#execute' do + it_behaves_like 'cache counters invalidator' + context 'valid params' do let(:service) { described_class.new(project, user, {}) } diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index 4b8589b2736583b20786d9cf0f29373164b9e426..0d6dd28e33218aedf4c630d0e77e8ed976a60f4d 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -70,7 +70,7 @@ describe Projects::DestroyService, services: true do end end - expect(project.team.members.count).to eq 1 + expect(project.team.members.count).to eq 2 end end diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb index 2112f1cf9ea48bfd338065212ee06f428613d128..5cf989105d0129353257922aaa839d52e37a0670 100644 --- a/spec/services/search_service_spec.rb +++ b/spec/services/search_service_spec.rb @@ -26,6 +26,15 @@ describe SearchService, services: true do expect(project).to eq accessible_project end + + it 'returns the project for guests' do + search_project = create :empty_project + search_project.add_guest(user) + + project = SearchService.new(user, project_id: search_project.id).project + + expect(project).to eq search_project + end end context 'when the project is not accessible' do diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 5a7cfaff7fb12bc95e3bdf7d3a64bddb5b8ec07e..c499b1bb34320c0cab88045bf0e4e9d1a4eb29e2 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -733,6 +733,26 @@ describe SystemNoteService, services: true do jira_service_settings end + def cross_reference(type, link_exists = false) + noteable = type == 'commit' ? commit : merge_request + + links = [] + if link_exists + url = if type == 'commit' + "#{Settings.gitlab.base_url}/#{project.namespace.path}/#{project.path}/commit/#{commit.id}" + else + "#{Settings.gitlab.base_url}/#{project.namespace.path}/#{project.path}/merge_requests/#{merge_request.iid}" + end + link = double(object: { 'url' => url }) + links << link + expect(link).to receive(:save!) + end + + allow(JIRA::Resource::Remotelink).to receive(:all).and_return(links) + + described_class.cross_reference(jira_issue, noteable, author) + end + noteable_types = %w(merge_requests commit) noteable_types.each do |type| @@ -740,24 +760,39 @@ describe SystemNoteService, services: true do it "blocks cross reference when #{type.underscore}_events is false" do jira_tracker.update("#{type}_events" => false) - noteable = type == "commit" ? commit : merge_request - result = described_class.cross_reference(jira_issue, noteable, author) - - expect(result).to eq("Events for #{noteable.class.to_s.underscore.humanize.pluralize.downcase} are disabled.") + expect(cross_reference(type)).to eq("Events for #{type.pluralize.humanize.downcase} are disabled.") end it "blocks cross reference when #{type.underscore}_events is true" do jira_tracker.update("#{type}_events" => true) - noteable = type == "commit" ? commit : merge_request - result = described_class.cross_reference(jira_issue, noteable, author) + expect(cross_reference(type)).to eq(success_message) + end + end + + context 'when a new cross reference is created' do + it 'creates a new comment and remote link' do + cross_reference(type) - expect(result).to eq(success_message) + expect(WebMock).to have_requested(:post, jira_api_comment_url(jira_issue)) + expect(WebMock).to have_requested(:post, jira_api_remote_link_url(jira_issue)) + end + end + + context 'when a link exists' do + it 'updates a link but does not create a new comment' do + expect(WebMock).not_to have_requested(:post, jira_api_comment_url(jira_issue)) + + cross_reference(type, true) end end end describe "new reference" do + before do + allow(JIRA::Resource::Remotelink).to receive(:all).and_return([]) + end + context 'for commits' do it "creates comment" do result = described_class.cross_reference(jira_issue, commit, author) @@ -837,6 +872,7 @@ describe SystemNoteService, services: true do describe "existing reference" do before do + allow(JIRA::Resource::Remotelink).to receive(:all).and_return([]) message = "[#{author.name}|http://localhost/#{author.username}] mentioned this issue in [a commit of #{project.path_with_namespace}|http://localhost/#{project.path_with_namespace}/commit/#{commit.id}]:\n'#{commit.title.chomp}'" allow_any_instance_of(JIRA::Resource::Issue).to receive(:comments).and_return([OpenStruct.new(body: message)]) end diff --git a/spec/services/users/refresh_authorized_projects_service_spec.rb b/spec/services/users/refresh_authorized_projects_service_spec.rb index b19374ef1a2d912928acc1985a53ceebbfe04d37..8c40d25e00c1b3942c35d221e6e637e79affa7bc 100644 --- a/spec/services/users/refresh_authorized_projects_service_spec.rb +++ b/spec/services/users/refresh_authorized_projects_service_spec.rb @@ -1,15 +1,13 @@ require 'spec_helper' describe Users::RefreshAuthorizedProjectsService do - let(:project) { create(:empty_project) } + # We're using let! here so that any expectations for the service class are not + # triggered twice. + let!(:project) { create(:empty_project) } + let(:user) { project.namespace.owner } let(:service) { described_class.new(user) } - def create_authorization(project, user, access_level = Gitlab::Access::MASTER) - ProjectAuthorization. - create!(project: project, user: user, access_level: access_level) - end - describe '#execute', :redis do it 'refreshes the authorizations using a lease' do expect_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain). @@ -31,7 +29,8 @@ describe Users::RefreshAuthorizedProjectsService do it 'updates the authorized projects of the user' do project2 = create(:empty_project) - to_remove = create_authorization(project2, user) + to_remove = user.project_authorizations. + create!(project: project2, access_level: Gitlab::Access::MASTER) expect(service).to receive(:update_authorizations). with([to_remove.project_id], [[user.id, project.id, Gitlab::Access::MASTER]]) @@ -40,7 +39,10 @@ describe Users::RefreshAuthorizedProjectsService do end it 'sets the access level of a project to the highest available level' do - to_remove = create_authorization(project, user, Gitlab::Access::DEVELOPER) + user.project_authorizations.delete_all + + to_remove = user.project_authorizations. + create!(project: project, access_level: Gitlab::Access::DEVELOPER) expect(service).to receive(:update_authorizations). with([to_remove.project_id], [[user.id, project.id, Gitlab::Access::MASTER]]) @@ -61,34 +63,10 @@ describe Users::RefreshAuthorizedProjectsService do service.update_authorizations([], []) end - - context 'when the authorized projects column is not set' do - before do - user.update!(authorized_projects_populated: nil) - end - - it 'populates the authorized projects column' do - service.update_authorizations([], []) - - expect(user.authorized_projects_populated).to eq true - end - end - - context 'when the authorized projects column is set' do - before do - user.update!(authorized_projects_populated: true) - end - - it 'does nothing' do - expect(user).not_to receive(:set_authorized_projects_column) - - service.update_authorizations([], []) - end - end end it 'removes authorizations that should be removed' do - authorization = create_authorization(project, user) + authorization = user.project_authorizations.find_by(project_id: project.id) service.update_authorizations([authorization.project_id]) @@ -96,6 +74,8 @@ describe Users::RefreshAuthorizedProjectsService do end it 'inserts authorizations that should be added' do + user.project_authorizations.delete_all + service.update_authorizations([], [[user.id, project.id, Gitlab::Access::MASTER]]) authorizations = user.project_authorizations @@ -105,16 +85,6 @@ describe Users::RefreshAuthorizedProjectsService do expect(authorizations[0].project_id).to eq(project.id) expect(authorizations[0].access_level).to eq(Gitlab::Access::MASTER) end - - it 'populates the authorized projects column' do - # make sure we start with a nil value no matter what the default in the - # factory may be. - user.update!(authorized_projects_populated: nil) - - service.update_authorizations([], [[user.id, project.id, Gitlab::Access::MASTER]]) - - expect(user.authorized_projects_populated).to eq(true) - end end describe '#fresh_access_levels_per_project' do @@ -163,7 +133,7 @@ describe Users::RefreshAuthorizedProjectsService do end end - context 'projects of subgroups of groups the user is a member of' do + context 'projects of subgroups of groups the user is a member of', :nested_groups do let(:group) { create(:group) } let(:nested_group) { create(:group, parent: group) } let!(:other_project) { create(:empty_project, group: nested_group) } @@ -191,7 +161,7 @@ describe Users::RefreshAuthorizedProjectsService do end end - context 'projects shared with subgroups of groups the user is a member of' do + context 'projects shared with subgroups of groups the user is a member of', :nested_groups do let(:group) { create(:group) } let(:nested_group) { create(:group, parent: group) } let(:other_project) { create(:empty_project) } @@ -208,8 +178,6 @@ describe Users::RefreshAuthorizedProjectsService do end describe '#current_authorizations_per_project' do - before { create_authorization(project, user) } - let(:hash) { service.current_authorizations_per_project } it 'returns a Hash' do @@ -233,13 +201,13 @@ describe Users::RefreshAuthorizedProjectsService do describe '#current_authorizations' do context 'without authorizations' do it 'returns an empty list' do + user.project_authorizations.delete_all + expect(service.current_authorizations.empty?).to eq(true) end end context 'with an authorization' do - before { create_authorization(project, user) } - let(:row) { service.current_authorizations.take } it 'returns the currently authorized projects' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 51571ddebe98fa202c99a5c635f264845c600b03..4c2eba8fa46812f92662db977101078bc99667c9 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -92,6 +92,14 @@ RSpec.configure do |config| Gitlab::Redis.with(&:flushall) Sidekiq.redis(&:flushall) end + + config.around(:each, :nested_groups) do |example| + example.run if Group.supports_nested_groups? + end + + config.around(:each, :postgresql) do |example| + example.run if Gitlab::Database.postgresql? + end end FactoryGirl::SyntaxRunner.class_eval do diff --git a/spec/support/issuable_shared_examples.rb b/spec/support/issuable_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..03011535351e0c1c56b11080eb20ed6afa613e78 --- /dev/null +++ b/spec/support/issuable_shared_examples.rb @@ -0,0 +1,7 @@ +shared_examples 'cache counters invalidator' do + it 'invalidates counter cache for assignees' do + expect_any_instance_of(User).to receive(:invalidate_merge_request_cache_counts) + + described_class.new(project, user, {}).execute(merge_request) + end +end diff --git a/spec/validators/dynamic_path_validator_spec.rb b/spec/validators/dynamic_path_validator_spec.rb index 5f998e78f075b0135feb2db31c62150d45afb4bb..8dbf3eecd23aeb1af4c9b57bee9e42a01e46755a 100644 --- a/spec/validators/dynamic_path_validator_spec.rb +++ b/spec/validators/dynamic_path_validator_spec.rb @@ -3,6 +3,28 @@ require 'spec_helper' describe DynamicPathValidator do let(:validator) { described_class.new(attributes: [:path]) } + def expect_handles_invalid_utf8 + expect { yield('\255invalid') }.to be_falsey + end + + describe '.valid_user_path' do + it 'handles invalid utf8' do + expect(described_class.valid_user_path?("a\0weird\255path")).to be_falsey + end + end + + describe '.valid_group_path' do + it 'handles invalid utf8' do + expect(described_class.valid_group_path?("a\0weird\255path")).to be_falsey + end + end + + describe '.valid_project_path' do + it 'handles invalid utf8' do + expect(described_class.valid_project_path?("a\0weird\255path")).to be_falsey + end + end + describe '#path_valid_for_record?' do context 'for project' do it 'calls valid_project_path?' do