diff options
author | Grzegorz Bizon <grzesiek.bizon@gmail.com> | 2017-06-05 13:13:38 +0200 |
---|---|---|
committer | Grzegorz Bizon <grzesiek.bizon@gmail.com> | 2017-06-05 13:13:38 +0200 |
commit | 5b718d482584ad95caf25e0405a4eedb2bdc372e (patch) | |
tree | e314360e363a6d6df2a70fe49b2eec68650f6828 | |
parent | 028423c2f51ff738d151df32254913664ac8e898 (diff) | |
parent | c34107608ecc5c36e80a748eb4c9b88d2b1157cf (diff) | |
download | gitlab-ce-5b718d482584ad95caf25e0405a4eedb2bdc372e.tar.gz |
Merge branch 'master' into feature/gb/migrate-pipeline-stages
* master: (524 commits)
Improve user experience around slash commands in instant comments
Fix LFS timeouts when trying to save large files
retryable? is now available for CommitStatus
Resolve "Documentation of `.gitlab-ci.yml` states incorrect example for variables"
Fix test failures
Add slugify project path to CI enviroment variables
Fixed typo: craeted -> created
32118 Make New environment empty state btn lowercase
Expose import_status in Projects API
32832 Remove overflow from comment form for confidential issues and vertically aligns confidential issue icon
Fix test failures
Allow manual bypass of auto_sign_in_with_provider
Fix keys seed
Allow users to be hard-deleted from the API
fixup some classnames and media queries
Enable the Style/PreferredHashMethods cop
Lint our factories creation in addition to their build
Don’t schedule workers from inside transactions
Allow scheduling from after_commit hooks
Forbid Sidekiq scheduling in transactions
...
Conflicts:
db/schema.rb
spec/spec_helper.rb
1053 files changed, 20984 insertions, 6751 deletions
diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 00000000000..e5636a13783 --- /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 638553d7bf7..b442e48a3d0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -150,6 +150,7 @@ stages: # Trigger a package build on omnibus-gitlab repository build-package: + image: ruby:2.3-alpine before_script: [] services: [] variables: @@ -429,6 +430,7 @@ gitlab:assets:compile: USE_DB: "false" SKIP_STORAGE_VALIDATION: "true" WEBPACK_REPORT: "true" + NO_COMPRESSION: "true" script: - yarn install --pure-lockfile --production --cache-folder .yarn-cache - bundle exec rake gitlab:assets:compile @@ -486,25 +488,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/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md index 58af062e75e..9d53a48409a 100644 --- a/.gitlab/issue_templates/Bug.md +++ b/.gitlab/issue_templates/Bug.md @@ -20,6 +20,12 @@ Please remove this notice if you're confident your issue isn't a duplicate. (How one can reproduce the issue - this is very important) +### Example Project + +(If possible, please create an example project here on GitLab.com that exhibits the problematic behaviour, and link to it here in the bug report) + +(If you are using an older version of GitLab, this will also determine whether the bug has been fixed in a more recent version) + ### What is the current *bug* behavior? (What actually happens) diff --git a/.rubocop.yml b/.rubocop.yml index 3cdafd96456..8f611a96702 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -390,6 +390,15 @@ Style/OpMethod: Style/ParenthesesAroundCondition: Enabled: true +# This cop (by default) checks for uses of methods Hash#has_key? and +# Hash#has_value? where it enforces Hash#key? and Hash#value? +# It is configurable to enforce the inverse, using `verbose` method +# names also. +# Configuration parameters: EnforcedStyle, SupportedStyles. +# SupportedStyles: short, verbose +Style/PreferredHashMethods: + Enabled: true + # Checks for an obsolete RuntimeException argument in raise/fail. Style/RedundantException: Enabled: true diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index cf30f5728c0..e2d9c37479d 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -236,13 +236,6 @@ Style/PerlBackrefs: Style/PredicateName: Enabled: false -# Offense count: 45 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. -# SupportedStyles: short, verbose -Style/PreferredHashMethods: - Enabled: false - # Offense count: 65 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. diff --git a/.scss-lint.yml b/.scss-lint.yml index 83c68309fa8..db234ad739c 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/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index 2d6c0bcf19c..ab0fa336dd0 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -5.0.4 +5.0.5 @@ -97,6 +97,7 @@ gem 'fog-google', '~> 0.5' gem 'fog-local', '~> 0.3' gem 'fog-openstack', '~> 0.1' gem 'fog-rackspace', '~> 0.1.1' +gem 'fog-aliyun', '~> 0.1.0' # for Google storage gem 'google-api-client', '~> 0.8.6' @@ -109,7 +110,7 @@ gem 'seed-fu', '~> 2.3.5' # Markdown and HTML processing gem 'html-pipeline', '~> 1.11.0' -gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie' +gem 'deckar01-task_list', '2.0.0' gem 'gitlab-markup', '~> 1.5.1' gem 'redcarpet', '~> 3.4' gem 'RedCloth', '~> 4.3.2' @@ -367,6 +368,10 @@ gem 'vmstat', '~> 2.3.0' gem 'sys-filesystem', '~> 1.1.6' # Gitaly GRPC client -gem 'gitaly', '~> 0.7.0' +gem 'gitaly', '~> 0.8.0' gem 'toml-rb', '~> 0.3.15', require: false + +# Feature toggles +gem 'flipper', '~> 0.10.2' +gem 'flipper-active_record', '~> 0.10.2' diff --git a/Gemfile.lock b/Gemfile.lock index 873cd8781ef..be1f6555851 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -141,10 +141,8 @@ GEM database_cleaner (1.5.3) debug_inspector (0.0.2) debugger-ruby_core_source (1.3.8) - deckar01-task_list (1.0.6) - activesupport (~> 4.0) + deckar01-task_list (2.0.0) html-pipeline - rack (~> 1.0) default_value_for (3.0.2) activerecord (>= 3.2.0, < 5.1) descendants_tracker (0.0.4) @@ -208,9 +206,18 @@ GEM path_expander (~> 1.0) ruby_parser (~> 3.0) sexp_processor (~> 4.0) + flipper (0.10.2) + flipper-active_record (0.10.2) + activerecord (>= 3.2, < 6) + flipper (~> 0.10.2) flowdock (0.7.1) httparty (~> 0.7) multi_json + fog-aliyun (0.1.0) + fog-core (~> 1.27) + fog-json (~> 1.0) + ipaddress (~> 0.8) + xml-simple (~> 1.1) fog-aws (0.13.0) fog-core (~> 1.38) fog-json (~> 1.0) @@ -265,7 +272,7 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) gherkin-ruby (0.3.2) - gitaly (0.7.0) + gitaly (0.8.0) google-protobuf (~> 3.1) grpc (~> 1.0) github-linguist (4.7.6) @@ -499,11 +506,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) @@ -896,7 +902,7 @@ DEPENDENCIES creole (~> 0.5.0) d3_rails (~> 3.5.0) database_cleaner (~> 1.5.0) - deckar01-task_list (= 1.0.6) + deckar01-task_list (= 2.0.0) default_value_for (~> 3.0.0) devise (~> 4.2) devise-two-factor (~> 3.0.0) @@ -910,6 +916,9 @@ DEPENDENCIES faraday (~> 0.11.0) ffaker (~> 2.4) flay (~> 2.8.0) + flipper (~> 0.10.2) + flipper-active_record (~> 0.10.2) + fog-aliyun (~> 0.1.0) fog-aws (~> 0.9) fog-core (~> 1.44) fog-google (~> 0.5) @@ -924,7 +933,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.2.0) - gitaly (~> 0.7.0) + gitaly (~> 0.8.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.5.1) @@ -1060,4 +1069,4 @@ DEPENDENCIES wikicloth (= 0.8.1) BUNDLED WITH - 1.14.6 + 1.15.0 diff --git a/app/assets/images/i2p-step.svg b/app/assets/images/i2p-step.svg new file mode 100644 index 00000000000..8886092ed82 --- /dev/null +++ b/app/assets/images/i2p-step.svg @@ -0,0 +1,4 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 120" enable-background="new 0 0 12 120"> + <path d="m12 6c0-3.309-2.691-6-6-6s-6 2.691-6 6c0 2.967 2.167 5.431 5 5.91v108.09h2v-108.09c2.833-.479 5-2.943 5-5.91m-6 4c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/> +</svg> diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index d7c62889dde..187fab084fd 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 e0a6f64dd42..0e4aa39226b 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 b214b5a7199..56a0fde5a91 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 1264280284c..b37698fe9ca 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 97f279e4be4..072a899e9f2 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); + .on('resize.build', _.throttle(this.sidebarOnResize.bind(this), 100)); 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,24 @@ window.Build = (function () { }; Build.prototype.toggleSidebar = function (shouldHide) { - const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined; + const shouldShow = !shouldHide; - this.$buildScroll.toggleClass('sidebar-expanded', shouldShow) - .toggleClass('sidebar-collapsed', shouldHide); - this.$truncatedInfo.toggleClass('sidebar-expanded', shouldShow) + this.$buildTrace + .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 +288,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/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js index 98698143d22..082fbafb740 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.js +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js @@ -118,7 +118,7 @@ export default Vue.component('pipelines-table', { eventHub.$on('refreshPipelines', this.fetchPipelines); }, - beforeDestroyed() { + beforeDestroy() { eventHub.$off('refreshPipelines'); }, diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js index 8a0fd3bb4a7..37ddca29e71 100644 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js @@ -16,6 +16,13 @@ const JumpToDiscussion = Vue.extend({ }; }, computed: { + buttonText: function () { + if (this.discussionId) { + return 'Jump to next unresolved discussion'; + } else { + return 'Jump to first unresolved discussion'; + } + }, allResolved: function () { return this.unresolvedDiscussionCount === 0; }, diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 2090a7e12d6..baa20d0c34a 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -118,13 +118,14 @@ import ShortcutsBlob from './shortcuts_blob'; shortcut_handler = new ShortcutsNavigation(); new UsersSelect(); break; - case 'projects:builds:show': + case 'projects:jobs:show': new Build(); break; case 'projects:merge_requests:index': case 'projects:issues:index': - if (gl.FilteredSearchManager) { - new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests'); + if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) { + const filteredSearchManager = new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests'); + filteredSearchManager.setup(); } Issuable.init(); new gl.IssuableBulkActions({ @@ -392,6 +393,9 @@ import ShortcutsBlob from './shortcuts_blob'; case 'users:show': new UserCallout(); break; + case 'admin:conversational_development_index:show': + new UserCallout(); + break; case 'snippets:show': new LineHighlighter(); new BlobViewer(); diff --git a/app/assets/javascripts/droplab/keyboard.js b/app/assets/javascripts/droplab/keyboard.js index 36740a430e1..02f1b805ce4 100644 --- a/app/assets/javascripts/droplab/keyboard.js +++ b/app/assets/javascripts/droplab/keyboard.js @@ -8,7 +8,7 @@ const Keyboard = function () { var isUpArrow = false; var isDownArrow = false; var removeHighlight = function removeHighlight(list) { - var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider)'), 0); + var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider):not(.hidden)'), 0); var listItems = []; for(var i = 0; i < itemElements.length; i++) { var listItem = itemElements[i]; diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/droplab/plugins/ajax_filter.js index a5427417031..1db20227a16 100644 --- a/app/assets/javascripts/droplab/plugins/ajax_filter.js +++ b/app/assets/javascripts/droplab/plugins/ajax_filter.js @@ -63,6 +63,9 @@ const AjaxFilter = { return AjaxCache.retrieve(url) .then((data) => { this._loadData(data, config); + if (config.onLoadingFinished) { + config.onLoadingFinished(data); + } }) .catch(config.onError); }, diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 266cd3966c6..111449bb8f7 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -194,6 +194,7 @@ window.DropzoneInput = (function() { $(child).val(beforeSelection + formattedText + afterSelection); textarea.setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length); textarea.style.height = `${textarea.scrollHeight}px`; + formTextarea.get(0).dispatchEvent(new Event('input')); return formTextarea.trigger('input'); }; diff --git a/app/assets/javascripts/environments/components/environment.vue b/app/assets/javascripts/environments/components/environment.vue index d4e13f3c84a..28597c799df 100644 --- a/app/assets/javascripts/environments/components/environment.vue +++ b/app/assets/javascripts/environments/components/environment.vue @@ -1,5 +1,6 @@ <script> /* global Flash */ +import Visibility from 'visibilityjs'; import EnvironmentsService from '../services/environments_service'; import environmentTable from './environments_table.vue'; import EnvironmentsStore from '../stores/environments_store'; @@ -7,6 +8,8 @@ import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import tablePagination from '../../vue_shared/components/table_pagination.vue'; import '../../lib/utils/common_utils'; import eventHub from '../event_hub'; +import Poll from '../../lib/utils/poll'; +import environmentsMixin from '../mixins/environments_mixin'; export default { @@ -16,6 +19,10 @@ export default { loadingIcon, }, + mixins: [ + environmentsMixin, + ], + data() { const environmentsData = document.querySelector('#environments-list-view').dataset; const store = new EnvironmentsStore(); @@ -35,6 +42,7 @@ export default { projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath, newEnvironmentPath: environmentsData.newEnvironmentPath, helpPagePath: environmentsData.helpPagePath, + isMakingRequest: false, // Pagination Properties, paginationInformation: {}, @@ -65,17 +73,43 @@ export default { * Toggles loading property. */ created() { + const scope = gl.utils.getParameterByName('scope') || this.visibility; + const page = gl.utils.getParameterByName('page') || this.pageNumber; + this.service = new EnvironmentsService(this.endpoint); - this.fetchEnvironments(); + const poll = new Poll({ + resource: this.service, + method: 'get', + data: { scope, page }, + successCallback: this.successCallback, + errorCallback: this.errorCallback, + notificationCallback: (isMakingRequest) => { + this.isMakingRequest = isMakingRequest; + + // We need to verify if any folder is open to also fecth it + this.openFolders = this.store.getOpenFolders(); + }, + }); + + if (!Visibility.hidden()) { + this.isLoading = true; + poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); - eventHub.$on('refreshEnvironments', this.fetchEnvironments); eventHub.$on('toggleFolder', this.toggleFolder); eventHub.$on('postAction', this.postAction); }, - beforeDestroyed() { - eventHub.$off('refreshEnvironments'); + beforeDestroy() { eventHub.$off('toggleFolder'); eventHub.$off('postAction'); }, @@ -104,29 +138,13 @@ export default { fetchEnvironments() { const scope = gl.utils.getParameterByName('scope') || this.visibility; - const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber; + const page = gl.utils.getParameterByName('page') || this.pageNumber; this.isLoading = true; - return this.service.get(scope, pageNumber) - .then(resp => ({ - headers: resp.headers, - body: resp.json(), - })) - .then((response) => { - this.store.storeAvailableCount(response.body.available_count); - this.store.storeStoppedCount(response.body.stopped_count); - this.store.storeEnvironments(response.body.environments); - this.store.setPagination(response.headers); - }) - .then(() => { - this.isLoading = false; - }) - .catch(() => { - this.isLoading = false; - // eslint-disable-next-line no-new - new Flash('An error occurred while fetching the environments.'); - }); + return this.service.get({ scope, page }) + .then(this.successCallback) + .catch(this.errorCallback); }, fetchChildEnvironments(folder, folderUrl) { @@ -146,9 +164,34 @@ export default { }, postAction(endpoint) { - this.service.postAction(endpoint) - .then(() => this.fetchEnvironments()) - .catch(() => new Flash('An error occured while making the request.')); + if (!this.isMakingRequest) { + this.isLoading = true; + + this.service.postAction(endpoint) + .then(() => this.fetchEnvironments()) + .catch(() => new Flash('An error occured while making the request.')); + } + }, + + successCallback(resp) { + this.saveData(resp); + + // If folders are open while polling we need to open them again + if (this.openFolders.length) { + this.openFolders.map((folder) => { + // TODO - Move this to the backend + const folderUrl = `${window.location.pathname}/folders/${folder.folderName}`; + + this.store.updateFolder(folder, 'isOpen', true); + return this.fetchChildEnvironments(folder, folderUrl); + }); + } + }, + + errorCallback() { + this.isLoading = false; + // eslint-disable-next-line no-new + new Flash('An error occurred while fetching the environments.'); }, }, }; @@ -212,7 +255,7 @@ export default { v-if="canCreateEnvironmentParsed" :href="newEnvironmentPath" class="btn btn-create js-new-environment-button"> - New Environment + New environment </a> </div> diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue index bd161c8a379..925503a01c4 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.vue +++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue @@ -1,12 +1,15 @@ <script> /* global Flash */ +import Visibility from 'visibilityjs'; import EnvironmentsService from '../services/environments_service'; import environmentTable from '../components/environments_table.vue'; import EnvironmentsStore from '../stores/environments_store'; import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import tablePagination from '../../vue_shared/components/table_pagination.vue'; +import Poll from '../../lib/utils/poll'; +import eventHub from '../event_hub'; +import environmentsMixin from '../mixins/environments_mixin'; import '../../lib/utils/common_utils'; -import '../../vue_shared/vue_resource_interceptor'; export default { components: { @@ -15,6 +18,10 @@ export default { loadingIcon, }, + mixins: [ + environmentsMixin, + ], + data() { const environmentsData = document.querySelector('#environments-folder-list-view').dataset; const store = new EnvironmentsStore(); @@ -76,33 +83,39 @@ export default { */ created() { const scope = gl.utils.getParameterByName('scope') || this.visibility; - const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber; - - const endpoint = `${this.endpoint}?scope=${scope}&page=${pageNumber}`; - - this.service = new EnvironmentsService(endpoint); - - this.isLoading = true; - - return this.service.get() - .then(resp => ({ - headers: resp.headers, - body: resp.json(), - })) - .then((response) => { - this.store.storeAvailableCount(response.body.available_count); - this.store.storeStoppedCount(response.body.stopped_count); - this.store.storeEnvironments(response.body.environments); - this.store.setPagination(response.headers); - }) - .then(() => { - this.isLoading = false; - }) - .catch(() => { - this.isLoading = false; - // eslint-disable-next-line no-new - new Flash('An error occurred while fetching the environments.', 'alert'); - }); + const page = gl.utils.getParameterByName('page') || this.pageNumber; + + this.service = new EnvironmentsService(this.endpoint); + + const poll = new Poll({ + resource: this.service, + method: 'get', + data: { scope, page }, + successCallback: this.successCallback, + errorCallback: this.errorCallback, + notificationCallback: (isMakingRequest) => { + this.isMakingRequest = isMakingRequest; + }, + }); + + if (!Visibility.hidden()) { + this.isLoading = true; + poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); + + eventHub.$on('postAction', this.postAction); + }, + + beforeDestroyed() { + eventHub.$off('postAction'); }, methods: { @@ -117,6 +130,37 @@ export default { gl.utils.visitUrl(param); return param; }, + + fetchEnvironments() { + const scope = gl.utils.getParameterByName('scope') || this.visibility; + const page = gl.utils.getParameterByName('page') || this.pageNumber; + + this.isLoading = true; + + return this.service.get({ scope, page }) + .then(this.successCallback) + .catch(this.errorCallback); + }, + + successCallback(resp) { + this.saveData(resp); + }, + + errorCallback() { + this.isLoading = false; + // eslint-disable-next-line no-new + new Flash('An error occurred while fetching the environments.'); + }, + + postAction(endpoint) { + if (!this.isMakingRequest) { + this.isLoading = true; + + this.service.postAction(endpoint) + .then(() => this.fetchEnvironments()) + .catch(() => new Flash('An error occured while making the request.')); + } + }, }, }; </script> diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js new file mode 100644 index 00000000000..25b24fbd6dc --- /dev/null +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -0,0 +1,17 @@ +export default { + methods: { + saveData(resp) { + const response = { + headers: resp.headers, + body: resp.json(), + }; + + this.isLoading = false; + + this.store.storeAvailableCount(response.body.available_count); + this.store.storeStoppedCount(response.body.stopped_count); + this.store.storeEnvironments(response.body.environments); + this.store.setPagination(response.headers); + }, + }, +}; diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js index 8adb53ea86d..03ab74b3338 100644 --- a/app/assets/javascripts/environments/services/environments_service.js +++ b/app/assets/javascripts/environments/services/environments_service.js @@ -10,7 +10,8 @@ export default class EnvironmentsService { this.folderResults = 3; } - get(scope, page) { + get(options = {}) { + const { scope, page } = options; return this.environments.get({ scope, page }); } diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index 158e7922e3c..8a2f6a473de 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -153,4 +153,10 @@ export default class EnvironmentsStore { return updatedEnvironments; } + getOpenFolders() { + const environments = this.state.environments; + + return environments.filter(env => env.isFolder && env.isOpen); + } + } diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js index 6b4338ca1d6..65c1b2050ac 100644 --- a/app/assets/javascripts/filtered_search/dropdown_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -18,6 +18,9 @@ class DropdownUser extends gl.FilteredSearchDropdown { }, searchValueFunction: this.getSearchInput.bind(this), loadingTemplate: this.loadingTemplate, + onLoadingFinished: () => { + this.hideCurrentUser(); + }, onError() { /* eslint-disable no-new */ new Flash('An error occured fetching the dropdown data.'); @@ -28,6 +31,11 @@ class DropdownUser extends gl.FilteredSearchDropdown { this.tokenKeys = tokenKeys; } + hideCurrentUser() { + const currenUserItem = this.dropdown.querySelector('.js-current-user'); + currenUserItem.classList.add('hidden'); + } + itemClicked(e) { super.itemClicked(e, selected => selected.querySelector('.dropdown-light-content').innerText.trim()); diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index 5c02a7a53d3..ef8fe071012 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -102,10 +102,13 @@ class DropdownUtils { if (token.classList.contains('js-visual-token')) { const name = token.querySelector('.name'); const value = token.querySelector('.value'); + const valueContainer = token.querySelector('.value-container'); const symbol = value && value.dataset.symbol ? value.dataset.symbol : ''; let valueText = ''; - if (value && value.innerText) { + if (valueContainer && valueContainer.dataset.originalValue) { + valueText = valueContainer.dataset.originalValue; + } else if (value && value.innerText) { valueText = value.innerText; } diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js index 5d48b8aacb2..132b6fe698a 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 58f2b75bd50..3be889c684b 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 f3003b86493..e9278140af0 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,6 +1,7 @@ -import AjaxCache from '~/lib/utils/ajax_cache'; -import '~/flash'; /* global Flash */ +import AjaxCache from '../lib/utils/ajax_cache'; +import '../flash'; /* global Flash */ import FilteredSearchContainer from './container'; +import UsersCache from '../lib/utils/users_cache'; class FilteredSearchVisualTokens { static getLastVisualTokenBeforeInput() { @@ -36,15 +37,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> `; @@ -75,22 +83,52 @@ class FilteredSearchVisualTokens { .catch(() => new Flash('An error occurred while fetching label colors.')); } + static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) { + if (tokenValue === 'none') { + return Promise.resolve(); + } + + const username = tokenValue.replace(/^@/, ''); + return UsersCache.retrieve(username) + .then((user) => { + if (!user) { + return; + } + + /* eslint-disable no-param-reassign */ + tokenValueContainer.dataset.originalValue = tokenValue; + tokenValueElement.innerHTML = ` + <img class="avatar s20" src="${user.avatar_url}" alt="${user.name}'s avatar"> + ${user.name} + `; + /* eslint-enable no-param-reassign */ + }) + // ignore error and leave username in the search bar + .catch(() => { }); + } + static renderVisualTokenValue(parentElement, tokenName, tokenValue) { const tokenValueContainer = parentElement.querySelector('.value-container'); - tokenValueContainer.querySelector('.value').innerText = tokenValue; + const tokenValueElement = tokenValueContainer.querySelector('.value'); + tokenValueElement.innerText = tokenValue; - if (tokenName.toLowerCase() === 'label') { + const tokenType = tokenName.toLowerCase(); + if (tokenType === 'label') { FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue); + } else if ((tokenType === 'author') || (tokenType === 'assignee')) { + FilteredSearchVisualTokens.updateUserTokenAppearance( + tokenValueContainer, tokenValueElement, tokenValue, + ); } } - 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 +152,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); } } @@ -146,6 +184,12 @@ class FilteredSearchVisualTokens { if (!lastVisualToken) return ''; + const valueContainer = lastVisualToken.querySelector('.value-container'); + const originalValue = valueContainer && valueContainer.dataset.originalValue; + if (originalValue) { + return originalValue; + } + const value = lastVisualToken.querySelector('.value'); const name = lastVisualToken.querySelector('.name'); @@ -198,17 +242,28 @@ class FilteredSearchVisualTokens { const inputLi = input.parentElement; tokenContainer.replaceChild(inputLi, token); - const name = token.querySelector('.name'); - const value = token.querySelector('.value'); + const nameElement = token.querySelector('.name'); + let value; - if (token.classList.contains('filtered-search-token') && value) { - FilteredSearchVisualTokens.addFilterVisualToken(name.innerText); - input.value = value.innerText; - } else { - // token is a search term - input.value = name.innerText; + if (token.classList.contains('filtered-search-token')) { + FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText); + + const valueContainerElement = token.querySelector('.value-container'); + value = valueContainerElement.dataset.originalValue; + + if (!value) { + const valueElement = valueContainerElement.querySelector('.value'); + value = valueElement.innerText; + } + } + + // token is a search term + if (!value) { + value = nameElement.innerText; } + input.value = value; + // Opens dropdown const inputEvent = new Event('input'); input.dispatchEvent(inputEvent); diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index eec30624ff2..ccff8f0ace7 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -7,8 +7,21 @@ window.Flash = (function() { return $(this).fadeOut(); }; - function Flash(message, type, parent) { - var flash, textDiv; + /** + * Flash banner supports different types of Flash configurations + * along with ability to provide actionConfig which can be used to show + * additional action or link on banner next to message + * + * @param {String} message Flash message + * @param {String} type Type of Flash, it can be `notice` or `alert` (default) + * @param {Object} parent Reference to Parent element under which Flash needs to appear + * @param {Object} actionConfig Map of config to show action on banner + * @param {String} href URL to which action link should point (default '#') + * @param {String} title Title of action + * @param {Function} clickHandler Method to call when action is clicked on + */ + function Flash(message, type, parent, actionConfig) { + var flash, textDiv, actionLink; if (type == null) { type = 'alert'; } @@ -30,6 +43,23 @@ window.Flash = (function() { text: message }); textDiv.appendTo(flash); + + if (actionConfig) { + const actionLinkConfig = { + class: 'flash-action', + href: actionConfig.href || '#', + text: actionConfig.title + }; + + if (!actionConfig.href) { + actionLinkConfig.role = 'button'; + } + + actionLink = $('<a/>', actionLinkConfig); + + actionLink.appendTo(flash); + this.flashContainer.on('click', '.flash-action', actionConfig.clickHandler); + } if (this.flashContainer.parent().hasClass('content-wrapper')) { textDiv.addClass('container-fluid container-limited'); } diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index b8a923cf619..401dec1a370 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -2,6 +2,7 @@ import emojiMap from 'emojis/digests.json'; import emojiAliases from 'emojis/aliases.json'; import { glEmojiTag } from '~/behaviors/gl_emoji'; import glRegexp from '~/lib/utils/regexp'; +import AjaxCache from '~/lib/utils/ajax_cache'; function sanitize(str) { return str.replace(/<(?:.|\n)*?>/gm, ''); @@ -35,6 +36,7 @@ class GfmAutoComplete { // This triggers at.js again // Needed for slash commands with suffixes (ex: /label ~) $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); + $input.on('clear-commands-cache.atwho', () => this.clearCache()); }); } @@ -375,11 +377,14 @@ class GfmAutoComplete { } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases))); } else { - $.getJSON(this.dataSources[GfmAutoComplete.atTypeMap[at]], (data) => { - this.loadData($input, at, data); - }).fail(() => { this.isLoadingData[at] = false; }); + AjaxCache.retrieve(this.dataSources[GfmAutoComplete.atTypeMap[at]], true) + .then((data) => { + this.loadData($input, at, data); + }) + .catch(() => { this.isLoadingData[at] = false; }); } } + loadData($input, at, data) { this.isLoadingData[at] = false; this.cachedData[at] = data; @@ -389,6 +394,10 @@ class GfmAutoComplete { return $input.trigger('keyup'); } + clearCache() { + this.cachedData = {}; + } + static isLoading(data) { let dataToInspect = data; if (data && data.length > 0) { diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 24c423dd01e..d34561e5512 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/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js index 4f226ff96ea..4bef60264bb 100644 --- a/app/assets/javascripts/gl_field_errors.js +++ b/app/assets/javascripts/gl_field_errors.js @@ -31,9 +31,13 @@ class GlFieldErrors { * and prevents disabling of invalid submit button by application.js */ catchInvalidFormSubmit (event) { - if (!event.currentTarget.checkValidity()) { - event.preventDefault(); - event.stopPropagation(); + const $form = $(event.currentTarget); + + if (!$form.attr('novalidate')) { + if (!event.currentTarget.checkValidity()) { + event.preventDefault(); + event.stopPropagation(); + } } } diff --git a/app/assets/javascripts/integrations/index.js b/app/assets/javascripts/integrations/index.js new file mode 100644 index 00000000000..10fe6bac0e8 --- /dev/null +++ b/app/assets/javascripts/integrations/index.js @@ -0,0 +1,7 @@ +/* eslint-disable no-new */ +import IntegrationSettingsForm from './integration_settings_form'; + +$(() => { + const integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + integrationSettingsForm.init(); +}); diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js new file mode 100644 index 00000000000..ddd3a6aab99 --- /dev/null +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -0,0 +1,123 @@ +/* global Flash */ + +export default class IntegrationSettingsForm { + constructor(formSelector) { + this.$form = $(formSelector); + + // Form Metadata + this.canTestService = this.$form.data('can-test'); + this.testEndPoint = this.$form.data('test-url'); + + // Form Child Elements + this.$serviceToggle = this.$form.find('#service_active'); + this.$submitBtn = this.$form.find('button[type="submit"]'); + this.$submitBtnLoader = this.$submitBtn.find('.js-btn-spinner'); + this.$submitBtnLabel = this.$submitBtn.find('.js-btn-label'); + } + + init() { + // Initialize View + this.toggleServiceState(this.$serviceToggle.is(':checked')); + + // Bind Event Listeners + this.$serviceToggle.on('change', e => this.handleServiceToggle(e)); + this.$submitBtn.on('click', e => this.handleSettingsSave(e)); + } + + handleSettingsSave(e) { + // Check if Service is marked active, as if not marked active, + // We can skip testing it and directly go ahead to allow form to + // be submitted + if (!this.$serviceToggle.is(':checked')) { + return; + } + + // Service was marked active so now we check; + // 1) If form contents are valid + // 2) If this service can be tested + // If both conditions are true, we override form submission + // and test the service using provided configuration. + if (this.$form.get(0).checkValidity() && this.canTestService) { + e.preventDefault(); + this.testSettings(this.$form.serialize()); + } + } + + handleServiceToggle(e) { + this.toggleServiceState($(e.currentTarget).is(':checked')); + } + + /** + * Change Form's validation enforcement based on service status (active/inactive) + */ + toggleServiceState(serviceActive) { + this.toggleSubmitBtnLabel(serviceActive); + if (serviceActive) { + this.$form.removeAttr('novalidate'); + } else if (!this.$form.attr('novalidate')) { + this.$form.attr('novalidate', 'novalidate'); + } + } + + /** + * Toggle Submit button label based on Integration status and ability to test service + */ + toggleSubmitBtnLabel(serviceActive) { + let btnLabel = 'Save changes'; + + if (serviceActive && this.canTestService) { + btnLabel = 'Test settings and save changes'; + } + + this.$submitBtnLabel.text(btnLabel); + } + + /** + * Toggle Submit button state based on provided boolean value of `saveTestActive` + * When enabled, it does two things, and reverts back when disabled + * + * 1. It shows load spinner on submit button + * 2. Makes submit button disabled + */ + toggleSubmitBtnState(saveTestActive) { + if (saveTestActive) { + this.$submitBtn.disable(); + this.$submitBtnLoader.removeClass('hidden'); + } else { + this.$submitBtn.enable(); + this.$submitBtnLoader.addClass('hidden'); + } + } + + /* eslint-disable promise/catch-or-return, no-new */ + /** + * Test Integration config + */ + testSettings(formData) { + this.toggleSubmitBtnState(true); + $.ajax({ + type: 'PUT', + url: this.testEndPoint, + data: formData, + }) + .done((res) => { + if (res.error) { + new Flash(res.message, null, null, { + title: 'Save anyway', + clickHandler: (e) => { + e.preventDefault(); + this.$form.submit(); + }, + }); + } else { + this.$form.submit(); + } + }) + .fail(() => { + new Flash('Something went wrong on our end.'); + }) + .always(() => { + this.toggleSubmitBtnState(false); + }); + } +} diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 770a0dcd27e..e14414d3f68 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -1,10 +1,15 @@ <script> +/* global Flash */ import Visibility from 'visibilityjs'; import Poll from '../../lib/utils/poll'; +import eventHub from '../event_hub'; import Service from '../services/index'; import Store from '../stores'; import titleComponent from './title.vue'; import descriptionComponent from './description.vue'; +import editedComponent from './edited.vue'; +import formComponent from './form.vue'; +import '../../lib/utils/url_utility'; export default { props: { @@ -12,15 +17,27 @@ export default { required: true, type: String, }, + canMove: { + required: true, + type: Boolean, + }, canUpdate: { required: true, type: Boolean, }, + canDestroy: { + required: true, + type: Boolean, + }, issuableRef: { type: String, required: true, }, - initialTitle: { + initialTitleHtml: { + type: String, + required: true, + }, + initialTitleText: { type: String, required: true, }, @@ -34,30 +51,160 @@ export default { required: false, default: '', }, + updatedAt: { + type: String, + required: false, + default: '', + }, + updatedByName: { + type: String, + required: false, + default: '', + }, + updatedByPath: { + type: String, + required: false, + default: '', + }, + issuableTemplates: { + type: Array, + required: false, + default: () => [], + }, + isConfidential: { + type: Boolean, + required: true, + }, + markdownPreviewUrl: { + type: String, + required: true, + }, + markdownDocs: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + projectsAutocompleteUrl: { + type: String, + required: true, + }, }, data() { const store = new Store({ - titleHtml: this.initialTitle, + titleHtml: this.initialTitleHtml, + titleText: this.initialTitleText, descriptionHtml: this.initialDescriptionHtml, descriptionText: this.initialDescriptionText, + updatedAt: this.updatedAt, + updatedByName: this.updatedByName, + updatedByPath: this.updatedByPath, }); return { store, state: store.state, + showForm: false, }; }, + computed: { + formState() { + return this.store.formState; + }, + hasUpdated() { + return !!this.state.updatedAt; + }, + }, components: { descriptionComponent, titleComponent, + editedComponent, + formComponent, + }, + methods: { + openForm() { + if (!this.showForm) { + this.showForm = true; + this.store.setFormState({ + title: this.state.titleText, + confidential: this.isConfidential, + description: this.state.descriptionText, + lockedWarningVisible: false, + move_to_project_id: 0, + updateLoading: false, + }); + } + }, + closeForm() { + this.showForm = false; + }, + updateIssuable() { + const canPostUpdate = this.store.formState.move_to_project_id !== 0 ? + confirm('Are you sure you want to move this issue to another project?') : true; // eslint-disable-line no-alert + + if (!canPostUpdate) { + this.store.setFormState({ + updateLoading: false, + }); + return; + } + + this.service.updateIssuable(this.store.formState) + .then(res => res.json()) + .then((data) => { + if (location.pathname !== data.web_url) { + gl.utils.visitUrl(data.web_url); + } else if (data.confidential !== this.isConfidential) { + gl.utils.visitUrl(location.pathname); + } + + return this.service.getData(); + }) + .then(res => res.json()) + .then((data) => { + this.store.updateState(data); + eventHub.$emit('close.form'); + }) + .catch(() => { + eventHub.$emit('close.form'); + return new Flash('Error updating issue'); + }); + }, + deleteIssuable() { + this.service.deleteIssuable() + .then(res => res.json()) + .then((data) => { + // Stop the poll so we don't get 404's with the issue not existing + this.poll.stop(); + + gl.utils.visitUrl(data.web_url); + }) + .catch(() => { + eventHub.$emit('close.form'); + return new Flash('Error deleting issue'); + }); + }, }, created() { - const resource = new Service(this.endpoint); - const poll = new Poll({ - resource, + this.service = new Service(this.endpoint); + this.poll = new Poll({ + resource: this.service, method: 'getData', successCallback: (res) => { - this.store.updateState(res.json()); + const data = res.json(); + const shouldUpdate = this.store.stateShouldUpdate(data); + + this.store.updateState(data); + + if (this.showForm && (shouldUpdate.title || shouldUpdate.description)) { + this.store.formState.lockedWarningVisible = true; + } }, errorCallback(err) { throw new Error(err); @@ -65,32 +212,62 @@ export default { }); if (!Visibility.hidden()) { - poll.makeRequest(); + this.poll.makeRequest(); } Visibility.change(() => { if (!Visibility.hidden()) { - poll.restart(); + this.poll.restart(); } else { - poll.stop(); + this.poll.stop(); } }); + + eventHub.$on('delete.issuable', this.deleteIssuable); + eventHub.$on('update.issuable', this.updateIssuable); + eventHub.$on('close.form', this.closeForm); + eventHub.$on('open.form', this.openForm); + }, + beforeDestroy() { + eventHub.$off('delete.issuable', this.deleteIssuable); + eventHub.$off('update.issuable', this.updateIssuable); + eventHub.$off('close.form', this.closeForm); + eventHub.$off('open.form', this.openForm); }, }; </script> <template> <div> - <title-component - :issuable-ref="issuableRef" - :title-html="state.titleHtml" - :title-text="state.titleText" /> - <description-component - v-if="state.descriptionHtml" - :can-update="canUpdate" - :description-html="state.descriptionHtml" - :description-text="state.descriptionText" - :updated-at="state.updatedAt" - :task-status="state.taskStatus" /> + <form-component + v-if="canUpdate && showForm" + :form-state="formState" + :can-move="canMove" + :can-destroy="canDestroy" + :issuable-templates="issuableTemplates" + :markdown-docs="markdownDocs" + :markdown-preview-url="markdownPreviewUrl" + :project-path="projectPath" + :project-namespace="projectNamespace" + :projects-autocomplete-url="projectsAutocompleteUrl" + /> + <div v-else> + <title-component + :issuable-ref="issuableRef" + :title-html="state.titleHtml" + :title-text="state.titleText" /> + <description-component + v-if="state.descriptionHtml" + :can-update="canUpdate" + :description-html="state.descriptionHtml" + :description-text="state.descriptionText" + :updated-at="state.updatedAt" + :task-status="state.taskStatus" /> + <edited-component + v-if="hasUpdated" + :updated-at="state.updatedAt" + :updated-by-name="state.updatedByName" + :updated-by-path="state.updatedByPath" /> + </div> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 4ad3eb7dfd7..5ae617356e0 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -16,20 +16,16 @@ type: String, required: true, }, - updatedAt: { - type: String, - required: true, - }, taskStatus: { type: String, - required: true, + required: false, + default: '', }, }, data() { return { preAnimation: false, pulseAnimation: false, - timeAgoEl: $('.js-issue-edited-ago'), }; }, watch: { @@ -37,12 +33,6 @@ this.animateChange(); this.$nextTick(() => { - const toolTipTime = gl.utils.formatDate(this.updatedAt); - - this.timeAgoEl.attr('datetime', this.updatedAt) - .attr('title', toolTipTime) - .tooltip('fixTitle'); - this.renderGFM(); }); }, @@ -83,6 +73,7 @@ <template> <div + v-if="descriptionHtml" class="description" :class="{ 'js-task-list-container': canUpdate diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue new file mode 100644 index 00000000000..8c81575fe6f --- /dev/null +++ b/app/assets/javascripts/issue_show/components/edit_actions.vue @@ -0,0 +1,79 @@ +<script> + import updateMixin from '../mixins/update'; + import eventHub from '../event_hub'; + + export default { + mixins: [updateMixin], + props: { + canDestroy: { + type: Boolean, + required: true, + }, + formState: { + type: Object, + required: true, + }, + }, + data() { + return { + deleteLoading: false, + }; + }, + computed: { + isSubmitEnabled() { + return this.formState.title.trim() !== ''; + }, + }, + methods: { + closeForm() { + eventHub.$emit('close.form'); + }, + deleteIssuable() { + // eslint-disable-next-line no-alert + if (confirm('Issue will be removed! Are you sure?')) { + this.deleteLoading = true; + + eventHub.$emit('delete.issuable'); + } + }, + }, + }; +</script> + +<template> + <div class="prepend-top-default append-bottom-default clearfix"> + <button + class="btn btn-save pull-left" + :class="{ disabled: formState.updateLoading || !isSubmitEnabled }" + type="submit" + :disabled="formState.updateLoading || !isSubmitEnabled" + @click.prevent="updateIssuable"> + Save changes + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" + v-if="formState.updateLoading"> + </i> + </button> + <button + class="btn btn-default pull-right" + type="button" + @click="closeForm"> + Cancel + </button> + <button + v-if="canDestroy" + class="btn btn-danger pull-right append-right-default" + :class="{ disabled: deleteLoading }" + type="button" + :disabled="deleteLoading" + @click="deleteIssuable"> + Delete + <i + class="fa fa-spinner fa-spin" + aria-hidden="true" + v-if="deleteLoading"> + </i> + </button> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue new file mode 100644 index 00000000000..d59e6d11032 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/edited.vue @@ -0,0 +1,56 @@ +<script> +import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; + +export default { + props: { + updatedAt: { + type: String, + required: false, + default: '', + }, + updatedByName: { + type: String, + required: false, + default: '', + }, + updatedByPath: { + type: String, + required: false, + default: '', + }, + }, + components: { + timeAgoTooltip, + }, + computed: { + hasUpdatedBy() { + return this.updatedByName && this.updatedByPath; + }, + }, +}; +</script> + +<template> + <small + class="edited-text" + > + Edited + <time-ago-tooltip + v-if="updatedAt" + placement="bottom" + :time="updatedAt" + /> + <span + v-if="hasUpdatedBy" + > + by + <a + class="author_link" + :href="updatedByPath" + > + <span>{{updatedByName}}</span> + </a> + </span> + </small> +</template> + diff --git a/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue new file mode 100644 index 00000000000..a0ff08e9111 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/confidential_checkbox.vue @@ -0,0 +1,23 @@ +<script> + export default { + props: { + formState: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <fieldset class="checkbox"> + <label for="issue-confidential"> + <input + type="checkbox" + value="1" + id="issue-confidential" + v-model="formState.confidential" /> + This issue is confidential and should only be visible to team members with at least Reporter access. + </label> + </fieldset> +</template> diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue new file mode 100644 index 00000000000..30a1be5cb50 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -0,0 +1,54 @@ +<script> + /* global Flash */ + import updateMixin from '../../mixins/update'; + import markdownField from '../../../vue_shared/components/markdown/field.vue'; + + export default { + mixins: [updateMixin], + props: { + formState: { + type: Object, + required: true, + }, + markdownPreviewUrl: { + type: String, + required: true, + }, + markdownDocs: { + type: String, + required: true, + }, + }, + components: { + markdownField, + }, + mounted() { + this.$refs.textarea.focus(); + }, + }; +</script> + +<template> + <div class="common-note-form"> + <label + class="sr-only" + for="issue-description"> + Description + </label> + <markdown-field + :markdown-preview-url="markdownPreviewUrl" + :markdown-docs="markdownDocs"> + <textarea + id="issue-description" + class="note-textarea js-gfm-input js-autosize markdown-area" + data-supports-slash-commands="false" + aria-label="Description" + v-model="formState.description" + ref="textarea" + slot="textarea" + placeholder="Write a comment or drag your files here..." + @keydown.meta.enter="updateIssuable"> + </textarea> + </markdown-field> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue new file mode 100644 index 00000000000..1c40b286513 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue @@ -0,0 +1,111 @@ +<script> + export default { + props: { + formState: { + type: Object, + required: true, + }, + issuableTemplates: { + type: Array, + required: false, + default: () => [], + }, + projectPath: { + type: String, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + }, + computed: { + issuableTemplatesJson() { + return JSON.stringify(this.issuableTemplates); + }, + }, + mounted() { + // Create the editor for the template + const editor = document.querySelector('.detail-page-description .note-textarea') || {}; + editor.setValue = (val) => { + this.formState.description = val; + }; + editor.getValue = () => this.formState.description; + + this.issuableTemplate = new gl.IssuableTemplateSelectors({ + $dropdowns: $(this.$refs.toggle), + editor, + }); + }, + }; +</script> + +<template> + <div + class="dropdown js-issuable-selector-wrap" + data-issuable-type="issue"> + <button + class="dropdown-menu-toggle js-issuable-selector" + type="button" + ref="toggle" + data-field-name="issuable_template" + data-selected="null" + data-toggle="dropdown" + :data-namespace-path="projectNamespace" + :data-project-path="projectPath" + :data-data="issuableTemplatesJson"> + <span class="dropdown-toggle-text"> + Choose a template + </span> + <i + aria-hidden="true" + class="fa fa-chevron-down"> + </i> + </button> + <div class="dropdown-menu dropdown-select"> + <div class="dropdown-title"> + Choose a template + <button + class="dropdown-title-button dropdown-menu-close" + aria-label="Close" + type="button"> + <i + aria-hidden="true" + class="fa fa-times dropdown-menu-close-icon"> + </i> + </button> + </div> + <div class="dropdown-input"> + <input + type="search" + class="dropdown-input-field" + placeholder="Filter" + autocomplete="off" /> + <i + aria-hidden="true" + class="fa fa-search dropdown-input-search"> + </i> + <i + role="button" + aria-label="Clear templates search input" + class="fa fa-times dropdown-input-clear js-dropdown-input-clear"> + </i> + </div> + <div class="dropdown-content"></div> + <div class="dropdown-footer"> + <ul class="dropdown-footer-list"> + <li> + <a class="no-template"> + No template + </a> + </li> + <li> + <a class="reset-template"> + Reset template + </a> + </li> + </ul> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/fields/project_move.vue b/app/assets/javascripts/issue_show/components/fields/project_move.vue new file mode 100644 index 00000000000..f811fb0de24 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/project_move.vue @@ -0,0 +1,83 @@ +<script> + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + export default { + mixins: [ + tooltipMixin, + ], + props: { + formState: { + type: Object, + required: true, + }, + projectsAutocompleteUrl: { + type: String, + required: true, + }, + }, + mounted() { + const $moveDropdown = $(this.$refs['move-dropdown']); + + $moveDropdown.select2({ + ajax: { + url: this.projectsAutocompleteUrl, + quietMillis: 125, + data(term, page, context) { + return { + search: term, + offset_id: context, + }; + }, + results(data) { + const more = data.length >= 50; + const context = data[data.length - 1] ? data[data.length - 1].id : null; + + return { + results: data, + more, + context, + }; + }, + }, + formatResult(project) { + return project.name_with_namespace; + }, + formatSelection(project) { + return project.name_with_namespace; + }, + }) + .on('change', (e) => { + this.formState.move_to_project_id = parseInt(e.target.value, 10); + }); + }, + beforeDestroy() { + $(this.$refs['move-dropdown']).select2('destroy'); + }, + }; +</script> + +<template> + <fieldset> + <label + for="issuable-move" + class="sr-only"> + Move + </label> + <div class="issuable-form-select-holder append-right-5"> + <input + ref="move-dropdown" + type="hidden" + id="issuable-move" + data-placeholder="Move to a different project" /> + </div> + <span + data-placement="auto top" + title="Moving an issue will copy the discussion to a different project and close it here. All participants will be notified of the new location." + ref="tooltip"> + <i + class="fa fa-question-circle" + aria-hidden="true"> + </i> + </span> + </fieldset> +</template> diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue new file mode 100644 index 00000000000..6556bf117e2 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/fields/title.vue @@ -0,0 +1,31 @@ +<script> + import updateMixin from '../../mixins/update'; + + export default { + mixins: [updateMixin], + props: { + formState: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <fieldset> + <label + class="sr-only" + for="issue-title"> + Title + </label> + <input + id="issue-title" + class="form-control" + type="text" + placeholder="Issue title" + aria-label="Issue title" + v-model="formState.title" + @keydown.meta.enter="updateIssuable" /> + </fieldset> +</template> diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue new file mode 100644 index 00000000000..76ec3dc9a5d --- /dev/null +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -0,0 +1,104 @@ +<script> + import lockedWarning from './locked_warning.vue'; + import titleField from './fields/title.vue'; + import descriptionField from './fields/description.vue'; + import editActions from './edit_actions.vue'; + import descriptionTemplate from './fields/description_template.vue'; + import projectMove from './fields/project_move.vue'; + import confidentialCheckbox from './fields/confidential_checkbox.vue'; + + export default { + props: { + canMove: { + type: Boolean, + required: true, + }, + canDestroy: { + type: Boolean, + required: true, + }, + formState: { + type: Object, + required: true, + }, + issuableTemplates: { + type: Array, + required: false, + default: () => [], + }, + markdownPreviewUrl: { + type: String, + required: true, + }, + markdownDocs: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + projectsAutocompleteUrl: { + type: String, + required: true, + }, + }, + components: { + lockedWarning, + titleField, + descriptionField, + descriptionTemplate, + editActions, + projectMove, + confidentialCheckbox, + }, + computed: { + hasIssuableTemplates() { + return this.issuableTemplates.length; + }, + }, + }; +</script> + +<template> + <form> + <locked-warning v-if="formState.lockedWarningVisible" /> + <div class="row"> + <div + class="col-sm-4 col-lg-3" + v-if="hasIssuableTemplates"> + <description-template + :form-state="formState" + :issuable-templates="issuableTemplates" + :project-path="projectPath" + :project-namespace="projectNamespace" /> + </div> + <div + :class="{ + 'col-sm-8 col-lg-9': hasIssuableTemplates, + 'col-xs-12': !hasIssuableTemplates, + }"> + <title-field + :form-state="formState" + :issuable-templates="issuableTemplates" /> + </div> + </div> + <description-field + :form-state="formState" + :markdown-preview-url="markdownPreviewUrl" + :markdown-docs="markdownDocs" /> + <confidential-checkbox + :form-state="formState" /> + <project-move + v-if="canMove" + :form-state="formState" + :projects-autocomplete-url="projectsAutocompleteUrl" /> + <edit-actions + :form-state="formState" + :can-destroy="canDestroy" /> + </form> +</template> diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue new file mode 100644 index 00000000000..1c2789f154a --- /dev/null +++ b/app/assets/javascripts/issue_show/components/locked_warning.vue @@ -0,0 +1,20 @@ +<script> + export default { + computed: { + currentPath() { + return location.pathname; + }, + }, + }; +</script> + +<template> + <div class="alert alert-danger"> + Someone edited the issue at the same time you did. Please check out + <a + :href="currentPath" + target="_blank" + rel="nofollow">the issue</a> + and make sure your changes will not unintentionally remove theirs. + </div> +</template> diff --git a/app/assets/javascripts/issue_show/event_hub.js b/app/assets/javascripts/issue_show/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/issue_show/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index f06e33dee60..14b2a1e18e9 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,42 +1,52 @@ import Vue from 'vue'; +import eventHub from './event_hub'; import issuableApp from './components/app.vue'; import '../vue_shared/vue_resource_interceptor'; -document.addEventListener('DOMContentLoaded', () => new Vue({ - el: document.getElementById('js-issuable-app'), - components: { - issuableApp, - }, - data() { - const issuableElement = this.$options.el; - const issuableTitleElement = issuableElement.querySelector('.title'); - const issuableDescriptionElement = issuableElement.querySelector('.wiki'); - const issuableDescriptionTextarea = issuableElement.querySelector('.js-task-list-field'); - const { - canUpdate, - endpoint, - issuableRef, - } = issuableElement.dataset; +document.addEventListener('DOMContentLoaded', () => { + const initialDataEl = document.getElementById('js-issuable-app-initial-data'); + const initialData = JSON.parse(initialDataEl.innerHTML.replace(/"/g, '"')); - return { - canUpdate: gl.utils.convertPermissionToBoolean(canUpdate), - endpoint, - issuableRef, - initialTitle: issuableTitleElement.innerHTML, - initialDescriptionHtml: issuableDescriptionElement ? issuableDescriptionElement.innerHTML : '', - initialDescriptionText: issuableDescriptionTextarea ? issuableDescriptionTextarea.textContent : '', - }; - }, - render(createElement) { - return createElement('issuable-app', { - props: { - canUpdate: this.canUpdate, - endpoint: this.endpoint, - issuableRef: this.issuableRef, - initialTitle: this.initialTitle, - initialDescriptionHtml: this.initialDescriptionHtml, - initialDescriptionText: this.initialDescriptionText, - }, - }); - }, -})); + $('.issuable-edit').on('click', (e) => { + e.preventDefault(); + + eventHub.$emit('open.form'); + }); + + return new Vue({ + el: document.getElementById('js-issuable-app'), + components: { + issuableApp, + }, + data() { + return { + ...initialData, + }; + }, + render(createElement) { + return createElement('issuable-app', { + props: { + canUpdate: this.canUpdate, + canDestroy: this.canDestroy, + canMove: this.canMove, + endpoint: this.endpoint, + issuableRef: this.issuableRef, + initialTitleHtml: this.initialTitleHtml, + initialTitleText: this.initialTitleText, + initialDescriptionHtml: this.initialDescriptionHtml, + initialDescriptionText: this.initialDescriptionText, + issuableTemplates: this.issuableTemplates, + isConfidential: this.isConfidential, + markdownPreviewUrl: this.markdownPreviewUrl, + markdownDocs: this.markdownDocs, + projectPath: this.projectPath, + projectNamespace: this.projectNamespace, + projectsAutocompleteUrl: this.projectsAutocompleteUrl, + updatedAt: this.updatedAt, + updatedByName: this.updatedByName, + updatedByPath: this.updatedByPath, + }, + }); + }, + }); +}); diff --git a/app/assets/javascripts/issue_show/mixins/animate.js b/app/assets/javascripts/issue_show/mixins/animate.js index eda6302aa8b..4816393da1f 100644 --- a/app/assets/javascripts/issue_show/mixins/animate.js +++ b/app/assets/javascripts/issue_show/mixins/animate.js @@ -4,7 +4,7 @@ export default { this.preAnimation = true; this.pulseAnimation = false; - this.$nextTick(() => { + setTimeout(() => { this.preAnimation = false; this.pulseAnimation = true; }); diff --git a/app/assets/javascripts/issue_show/mixins/update.js b/app/assets/javascripts/issue_show/mixins/update.js new file mode 100644 index 00000000000..72be65b426f --- /dev/null +++ b/app/assets/javascripts/issue_show/mixins/update.js @@ -0,0 +1,10 @@ +import eventHub from '../event_hub'; + +export default { + methods: { + updateIssuable() { + this.formState.updateLoading = true; + eventHub.$emit('update.issuable'); + }, + }, +}; diff --git a/app/assets/javascripts/issue_show/services/index.js b/app/assets/javascripts/issue_show/services/index.js index 348ad8d6813..6f0fd0b1768 100644 --- a/app/assets/javascripts/issue_show/services/index.js +++ b/app/assets/javascripts/issue_show/services/index.js @@ -7,10 +7,23 @@ export default class Service { constructor(endpoint) { this.endpoint = endpoint; - this.resource = Vue.resource(this.endpoint); + this.resource = Vue.resource(`${this.endpoint}.json`, {}, { + realtimeChanges: { + method: 'GET', + url: `${this.endpoint}/realtime_changes`, + }, + }); } getData() { - return this.resource.get(); + return this.resource.realtimeChanges(); + } + + deleteIssuable() { + return this.resource.delete(); + } + + updateIssuable(data) { + return this.resource.update(data); } } diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js index 8e89a2b7730..27c2d349f52 100644 --- a/app/assets/javascripts/issue_show/stores/index.js +++ b/app/assets/javascripts/issue_show/stores/index.js @@ -1,16 +1,30 @@ export default class Store { constructor({ titleHtml, + titleText, descriptionHtml, descriptionText, + updatedAt, + updatedByName, + updatedByPath, }) { this.state = { titleHtml, - titleText: '', + titleText, descriptionHtml, descriptionText, taskStatus: '', - updatedAt: '', + updatedAt, + updatedByName, + updatedByPath, + }; + this.formState = { + title: '', + confidential: false, + description: '', + lockedWarningVisible: false, + move_to_project_id: 0, + updateLoading: false, }; } @@ -21,5 +35,18 @@ export default class Store { this.state.descriptionText = data.description_text; this.state.taskStatus = data.task_status; this.state.updatedAt = data.updated_at; + this.state.updatedByName = data.updated_by_name; + this.state.updatedByPath = data.updated_by_path; + } + + stateShouldUpdate(data) { + return { + title: this.state.titleText !== data.title_text, + description: this.state.descriptionText !== data.description_text, + }; + } + + setFormState(state) { + this.formState = Object.assign(this.formState, state); } } diff --git a/app/assets/javascripts/lib/utils/ajax_cache.js b/app/assets/javascripts/lib/utils/ajax_cache.js index f1fe95e12e8..7477b5a5214 100644 --- a/app/assets/javascripts/lib/utils/ajax_cache.js +++ b/app/assets/javascripts/lib/utils/ajax_cache.js @@ -6,8 +6,8 @@ class AjaxCache extends Cache { this.pendingRequests = { }; } - retrieve(endpoint) { - if (this.hasData(endpoint)) { + retrieve(endpoint, forceRetrieve) { + if (this.hasData(endpoint) && !forceRetrieve) { return Promise.resolve(this.get(endpoint)); } diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js index 66f39122a66..973d6119158 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 f1b07408671..57394097944 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/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index b43c1c3aac6..601d01e1be1 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -170,7 +170,7 @@ gl.text.init = function(form) { }); }; gl.text.removeListeners = function(form) { - return $('.js-md', form).off(); + return $('.js-md', form).off('click'); }; gl.text.humanize = function(string) { return string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index b9d2fc25c39..3328ff9cc23 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -66,7 +66,8 @@ w.gl.utils.removeParamQueryString = function(url, param) { })()).join('&'); }; w.gl.utils.removeParams = (params) => { - const url = new URL(window.location.href); + const url = document.createElement('a'); + url.href = window.location.href; params.forEach((param) => { url.search = w.gl.utils.removeParamQueryString(url.search, param); }); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index f0958972130..1ac82b7e291 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 22032d0f914..894ed81b044 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/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index 3e8240d10ec..814d2ea92b4 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -30,7 +30,7 @@ | \\s\\$(?!\\$) ) - (.+?) + ((.|\\n)+?) ( \\s\\\\end{[a-zA-Z]+}$ | @@ -45,15 +45,25 @@ let inline = false; if (typeof katex !== 'undefined') { - const katexString = text.replace(/\\/g, '\\'); - const matches = new RegExp(katexRegexString, 'gi').exec(katexString); + const katexString = text.replace(/&/g, '&') + .replace(/&=&/g, '\\space=\\space') + .replace(/<(\/?)em>/g, '_'); + const regex = new RegExp(katexRegexString, 'gi'); + const matchLocation = katexString.search(regex); + const numberOfMatches = katexString.match(regex); - if (matches && matches.length > 0) { - if (matches[1].trim() === '$' && matches[3].trim() === '$') { + if (numberOfMatches && numberOfMatches.length !== 0) { + if (matchLocation > 0) { + let matches = regex.exec(katexString); inline = true; - text = `${katexString.replace(matches[0], '')} ${katex.renderToString(matches[2])}`; + while (matches !== null) { + const renderedKatex = katex.renderToString(matches[0].replace(/\$/g, '')); + text = `${text.replace(matches[0], ` ${renderedKatex}`)}`; + matches = regex.exec(katexString); + } } else { + const matches = regex.exec(katexString); text = katex.renderToString(matches[2]); } } @@ -79,7 +89,7 @@ }, computed: { markdown() { - return marked(this.cell.source.join('')); + return marked(this.cell.source.join('').replace(/\\/g, '\\\\')); }, }, }; diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index b0b1cfd6c8a..929965de5c1 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 */ @@ -10,6 +16,7 @@ import autosize from 'vendor/autosize'; import Dropzone from 'dropzone'; import 'vendor/jquery.caret'; // required by jquery.atwho import 'vendor/jquery.atwho'; +import AjaxCache from '~/lib/utils/ajax_cache'; import CommentTypeToggle from './comment_type_toggle'; import './autosave'; import './dropzone_input'; @@ -57,10 +64,9 @@ 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 = []; this.cleanBinding(); this.addBinding(); @@ -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); } @@ -319,6 +325,9 @@ const normalizeNewlines = function(str) { if (Notes.isNewNote(noteEntity, this.note_ids)) { this.note_ids.push(noteEntity.id); + if ($notesList.length) { + $notesList.find('.system-note.being-posted').remove(); + } const $newNote = Notes.animateAppendNote(noteEntity.html, $notesList); this.setupNewNote($newNote); @@ -368,8 +377,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 +395,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 +406,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 +459,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 +476,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 +489,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 +521,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 +679,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 +732,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 +750,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 +778,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 +798,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 +818,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 +846,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 +855,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 +888,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 +912,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 +955,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 +975,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 +987,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); }; /* @@ -1111,12 +1121,14 @@ const normalizeNewlines = function(str) { }; Notes.prototype.addFlash = function(...flashParams) { - this.flashErrors.push(new Flash(...flashParams)); + this.flashInstance = new Flash(...flashParams); }; Notes.prototype.clearFlash = function() { - this.flashErrors.forEach(flash => flash.flashContainer.remove()); - this.flashErrors = []; + if (this.flashInstance && this.flashInstance.flashContainer) { + this.flashInstance.flashContainer.hide(); + this.flashInstance = null; + } }; Notes.prototype.cleanForm = function($form) { @@ -1180,7 +1192,7 @@ const normalizeNewlines = function(str) { Notes.prototype.getFormData = function($form) { return { formData: $form.serialize(), - formContent: $form.find('.js-note-text').val(), + formContent: _.escape($form.find('.js-note-text').val()), formAction: $form.attr('action'), }; }; @@ -1200,19 +1212,46 @@ const normalizeNewlines = function(str) { }; /** + * Gets appropriate description from slash commands found in provided `formContent` + */ + Notes.prototype.getSlashCommandDescription = function (formContent, availableSlashCommands = []) { + let tempFormContent; + + // Identify executed slash commands from `formContent` + const executedCommands = availableSlashCommands.filter((command, index) => { + const commandRegex = new RegExp(`/${command.name}`); + return commandRegex.test(formContent); + }); + + if (executedCommands && executedCommands.length) { + if (executedCommands.length > 1) { + tempFormContent = 'Applying multiple commands'; + } else { + const commandDescription = executedCommands[0].description.toLowerCase(); + tempFormContent = `Applying command to ${commandDescription}`; + } + } else { + tempFormContent = 'Applying command'; + } + + return tempFormContent; + }; + + /** * Create placeholder note DOM element populated with comment body * that we will show while comment is being posted. * Once comment is _actually_ posted on server, we will have final element * in response that we will show in place of this temporary element. */ - Notes.prototype.createPlaceholderNote = function({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname }) { + Notes.prototype.createPlaceholderNote = function ({ formContent, uniqueId, isDiscussionNote, currentUsername, currentUserFullname, currentUserAvatar }) { const discussionClass = isDiscussionNote ? 'discussion' : ''; - const escapedFormContent = _.escape(formContent); const $tempNote = $( `<li id="${uniqueId}" class="note being-posted fade-in-half timeline-entry"> <div class="timeline-entry-inner"> <div class="timeline-icon"> - <a href="/${currentUsername}"><span class="dummy-avatar"></span></a> + <a href="/${currentUsername}"> + <img class="avatar s40" src="${currentUserAvatar}"> + </a> </div> <div class="timeline-content ${discussionClass}"> <div class="note-header"> @@ -1225,7 +1264,7 @@ const normalizeNewlines = function(str) { </div> <div class="note-body"> <div class="note-text"> - <p>${escapedFormContent}</p> + <p>${formContent}</p> </div> </div> </div> @@ -1237,6 +1276,23 @@ const normalizeNewlines = function(str) { }; /** + * Create Placeholder System Note DOM element populated with slash command description + */ + Notes.prototype.createPlaceholderSystemNote = function ({ formContent, uniqueId }) { + const $tempNote = $( + `<li id="${uniqueId}" class="note system-note timeline-entry being-posted fade-in-half"> + <div class="timeline-entry-inner"> + <div class="timeline-content"> + <i>${formContent}</i> + </div> + </div> + </li>` + ); + + return $tempNote; + }; + + /** * This method does following tasks step-by-step whenever a new comment * is submitted by user (both main thread comments as well as discussion comments). * @@ -1267,7 +1323,9 @@ const normalizeNewlines = function(str) { const isDiscussionForm = $form.hasClass('js-discussion-note-form'); const isDiscussionResolve = $submitBtn.hasClass('js-comment-resolve-button'); const { formData, formContent, formAction } = this.getFormData($form); - const uniqueId = _.uniqueId('tempNote_'); + let noteUniqueId; + let systemNoteUniqueId; + let hasSlashCommands = false; let $notesContainer; let tempFormContent; @@ -1288,16 +1346,28 @@ const normalizeNewlines = function(str) { tempFormContent = formContent; if (this.hasSlashCommands(formContent)) { tempFormContent = this.stripSlashCommands(formContent); + hasSlashCommands = true; } + // Show placeholder note if (tempFormContent) { - // Show placeholder note + noteUniqueId = _.uniqueId('tempNote_'); $notesContainer.append(this.createPlaceholderNote({ formContent: tempFormContent, - uniqueId, + uniqueId: noteUniqueId, isDiscussionNote, currentUsername: gon.current_username, currentUserFullname: gon.current_user_fullname, + currentUserAvatar: gon.current_user_avatar_url, + })); + } + + // Show placeholder system note + if (hasSlashCommands) { + systemNoteUniqueId = _.uniqueId('tempSystemNote_'); + $notesContainer.append(this.createPlaceholderSystemNote({ + formContent: this.getSlashCommandDescription(formContent, AjaxCache.get(gl.GfmAutoComplete.dataSources.commands)), + uniqueId: systemNoteUniqueId, })); } @@ -1315,7 +1385,13 @@ const normalizeNewlines = function(str) { gl.utils.ajaxPost(formAction, formData) .then((note) => { // Submission successful! remove placeholder - $notesContainer.find(`#${uniqueId}`).remove(); + $notesContainer.find(`#${noteUniqueId}`).remove(); + + // Reset cached commands list when command is applied + if (hasSlashCommands) { + $form.find('textarea.js-note-text').trigger('clear-commands-cache.atwho'); + } + // Clear previous form errors this.clearFlashWrapper(); @@ -1352,7 +1428,11 @@ const normalizeNewlines = function(str) { $form.trigger('ajax:success', [note]); }).fail(() => { // Submission failed, remove placeholder note and show Flash error message - $notesContainer.find(`#${uniqueId}`).remove(); + $notesContainer.find(`#${noteUniqueId}`).remove(); + + if (hasSlashCommands) { + $notesContainer.find(`#${systemNoteUniqueId}`).remove(); + } // Show form again on UI on failure if (isDiscussionForm && $notesContainer.length) { diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 14c98847d93..77cbaeb43ef 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/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue new file mode 100644 index 00000000000..4f6c5c177cf --- /dev/null +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -0,0 +1,97 @@ +<script> +import ciHeader from '../../vue_shared/components/header_ci_component.vue'; +import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + +export default { + name: 'PipelineHeaderSection', + props: { + pipeline: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: true, + }, + }, + components: { + ciHeader, + loadingIcon, + }, + + data() { + return { + actions: this.getActions(), + }; + }, + + computed: { + status() { + return this.pipeline.details && this.pipeline.details.status; + }, + shouldRenderContent() { + return !this.isLoading && Object.keys(this.pipeline).length; + }, + }, + + methods: { + postAction(action) { + const index = this.actions.indexOf(action); + + this.$set(this.actions[index], 'isLoading', true); + + eventHub.$emit('headerPostAction', action); + }, + + getActions() { + const actions = []; + + if (this.pipeline.retry_path) { + actions.push({ + label: 'Retry', + path: this.pipeline.retry_path, + cssClass: 'js-retry-button btn btn-inverted-secondary', + type: 'button', + isLoading: false, + }); + } + + if (this.pipeline.cancel_path) { + actions.push({ + label: 'Cancel running', + path: this.pipeline.cancel_path, + cssClass: 'js-btn-cancel-pipeline btn btn-danger', + type: 'button', + isLoading: false, + }); + } + + return actions; + }, + }, + + watch: { + pipeline() { + this.actions = this.getActions(); + }, + }, +}; +</script> +<template> + <div class="pipeline-header-container"> + <ci-header + v-if="shouldRenderContent" + :status="status" + item-name="Pipeline" + :item-id="pipeline.id" + :time="pipeline.created_at" + :user="pipeline.user" + :actions="actions" + @actionClicked="postAction" + /> + <loading-icon + v-else + size="2"/> + </div> +</template> 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 7cd2e0f9366..00000000000 --- 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 00000000000..4781a8ff1da --- /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.path" + :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 b7a6b5d8479..00000000000 --- 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 00000000000..bfc416da50b --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -0,0 +1,70 @@ +/* global Flash */ + +import Vue from 'vue'; +import PipelinesMediator from './pipeline_details_mediatior'; +import pipelineGraph from './components/graph/graph_component.vue'; +import pipelineHeader from './components/header_component.vue'; +import eventHub from './event_hub'; + +document.addEventListener('DOMContentLoaded', () => { + const dataset = document.querySelector('.js-pipeline-details-vue').dataset; + + const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); + + mediator.fetchPipeline(); + + // eslint-disable-next-line + 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, + }, + }); + }, + }); + + // eslint-disable-next-line + new Vue({ + el: '#js-pipeline-header-vue', + data() { + return { + mediator, + }; + }, + components: { + pipelineHeader, + }, + created() { + eventHub.$on('headerPostAction', this.postAction); + }, + beforeDestroy() { + eventHub.$off('headerPostAction', this.postAction); + }, + methods: { + postAction(action) { + this.mediator.service.postAction(action.path) + .then(() => this.mediator.refreshPipeline()) + .catch(() => new Flash('An error occurred while making the request.')); + }, + }, + render(createElement) { + return createElement('pipeline-header', { + props: { + isLoading: this.mediator.state.isLoading, + pipeline: this.mediator.store.state.pipeline, + }, + }); + }, + }); +}); 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 00000000000..82537ea06f5 --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_mediatior.js @@ -0,0 +1,59 @@ +/* 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(); + } else { + this.refreshPipeline(); + } + + 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.'); + } + + refreshPipeline() { + this.service.getPipeline() + .then(response => this.successCallback(response)) + .catch(() => this.errorCallback()); + } +} diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js index d6952d1ee5f..9f247af1dec 100644 --- a/app/assets/javascripts/pipelines/pipelines.js +++ b/app/assets/javascripts/pipelines/pipelines.js @@ -169,7 +169,7 @@ export default { eventHub.$on('refreshPipelines', this.fetchPipelines); }, - beforeDestroyed() { + beforeDestroy() { eventHub.$off('refreshPipelines'); }, diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js index f1cc60c1ee0..3e0c52c7726 100644 --- a/app/assets/javascripts/pipelines/services/pipeline_service.js +++ b/app/assets/javascripts/pipelines/services/pipeline_service.js @@ -11,4 +11,9 @@ export default class PipelineService { getPipeline() { return this.pipeline.get(); } + + // eslint-disable-next-line + postAction(endpoint) { + return Vue.http.post(`${endpoint}.json`); + } } diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js index b21f84b4545..e2285494e62 100644 --- a/app/assets/javascripts/pipelines/services/pipelines_service.js +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -33,8 +33,6 @@ export default class PipelinesService { /** * Post request for all pipelines actions. - * Endpoint content type needs to be: - * `Content-Type:application/x-www-form-urlencoded` * * @param {String} endpoint * @return {Promise} diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js index 86ab50d8f1e..052e34a8aef 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 0ff0a3b6cc4..9896b88d487 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/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index dace03554e8..51448252c0f 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -77,7 +77,9 @@ import './shortcuts_navigation'; ShortcutsIssuable.prototype.editIssue = function() { var $editBtn; $editBtn = $('.issuable-edit'); - return gl.utils.visitUrl($editBtn.attr('href')); + // Need to click the element as on issues, editing is inline + // on merge request, editing is on a different page + $editBtn.get(0).click(); }; ShortcutsIssuable.prototype.openSidebarDropdown = function(name) { diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index bacb26734c9..c44892dae3d 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -4,7 +4,7 @@ window.SingleFileDiff = (function() { var COLLAPSED_HTML, ERROR_HTML, LOADING_HTML, WRAPPER; - WRAPPER = '<div class="diff-content diff-wrap-lines"></div>'; + WRAPPER = '<div class="diff-content"></div>'; LOADING_HTML = '<i class="fa fa-spinner fa-spin"></i>'; diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index 3392cb9da29..419c458ff34 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -1,6 +1,6 @@ /* global Flash */ -import 'vendor/task_list'; +import 'deckar01-task_list'; class TaskList { constructor(options = {}) { diff --git a/app/assets/javascripts/user_callout.js b/app/assets/javascripts/user_callout.js index b9d57cbcad4..ff2208baeab 100644 --- a/app/assets/javascripts/user_callout.js +++ b/app/assets/javascripts/user_callout.js @@ -1,11 +1,10 @@ import Cookies from 'js-cookie'; -const USER_CALLOUT_COOKIE = 'user_callout_dismissed'; - export default class UserCallout { - constructor() { - this.isCalloutDismissed = Cookies.get(USER_CALLOUT_COOKIE); - this.userCalloutBody = $('.user-callout'); + constructor(className = 'user-callout') { + this.userCalloutBody = $(`.${className}`); + this.cookieName = this.userCalloutBody.data('uid'); + this.isCalloutDismissed = Cookies.get(this.cookieName); this.init(); } @@ -18,7 +17,7 @@ export default class UserCallout { dismissCallout(e) { const $currentTarget = $(e.currentTarget); - Cookies.set(USER_CALLOUT_COOKIE, 'true', { expires: 365 }); + Cookies.set(this.cookieName, 'true', { expires: 365 }); if ($currentTarget.hasClass('close')) { this.userCalloutBody.remove(); diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index aea3592c6ba..ec45253e50b 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 486b13e60af..8155218681c 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 d866d4e94b0..fcd4fdaf09f 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 bfe30ee4c08..fe5e1bbb55c 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 cd65ac069c5..43ef468c303 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 99600b6664e..2339a00ddd0 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 c07bd25e6fd..69bc1436284 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/commit.js b/app/assets/javascripts/vue_shared/components/commit.js index 23bc5fbc034..8e22057e2e9 100644 --- a/app/assets/javascripts/vue_shared/components/commit.js +++ b/app/assets/javascripts/vue_shared/components/commit.js @@ -91,7 +91,7 @@ export default { hasAuthor() { return this.author && this.author.avatar_url && - this.author.web_url && + this.author.path && this.author.username; }, @@ -140,7 +140,7 @@ export default { <user-avatar-link v-if="hasAuthor" class="avatar-image-container" - :link-href="author.web_url" + :link-href="author.path" :img-src="author.avatar_url" :img-alt="userImageAltDescription" :tooltip-text="author.username" 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 00000000000..fe6d6a792e7 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -0,0 +1,133 @@ +<script> +import ciIconBadge from './ci_badge_link.vue'; +import loadingIcon from './loading_icon.vue'; +import timeagoTooltip from './time_ago_tooltip.vue'; +import tooltipMixin from '../mixins/tooltip'; +import userAvatarImage from './user_avatar/user_avatar_image.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: false, + default: () => ({}), + }, + actions: { + type: Array, + required: false, + default: () => [], + }, + }, + + mixins: [ + tooltipMixin, + ], + + components: { + ciIconBadge, + loadingIcon, + timeagoTooltip, + userAvatarImage, + }, + + computed: { + userAvatarAltText() { + return `${this.user.name}'s avatar`; + }, + }, + + methods: { + onClickAction(action) { + this.$emit('actionClicked', action); + }, + }, +}; +</script> +<template> + <header class="page-content-header"> + <section class="header-main-content"> + + <ci-icon-badge :status="status" /> + + <strong> + {{itemName}} #{{itemId}} + </strong> + + triggered + + <timeago-tooltip :time="time" /> + + by + + <template v-if="user"> + <a + :href="user.path" + :title="user.email" + class="js-user-link commit-committer-link" + ref="tooltip"> + + <user-avatar-image + :img-src="user.avatar_url" + :img-alt="userAvatarAltText" + :tooltip-text="user.name" + :img-size="24" + /> + + {{user.name}} + </a> + </template> + </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)" + :disabled="action.isLoading" + :class="action.cssClass" + type="button"> + {{action.label}} + + <i + v-show="action.isLoading" + class="fa fa-spin fa-spinner" + aria-hidden="true"> + </i> + </button> + </template> + </section> + </header> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue new file mode 100644 index 00000000000..e6977681e96 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -0,0 +1,107 @@ +<script> + /* global Flash */ + import markdownHeader from './header.vue'; + import markdownToolbar from './toolbar.vue'; + + export default { + props: { + markdownPreviewUrl: { + type: String, + required: false, + default: '', + }, + markdownDocs: { + type: String, + required: true, + }, + }, + data() { + return { + markdownPreview: '', + markdownPreviewLoading: false, + previewMarkdown: false, + }; + }, + components: { + markdownHeader, + markdownToolbar, + }, + methods: { + toggleMarkdownPreview() { + this.previewMarkdown = !this.previewMarkdown; + + if (!this.previewMarkdown) { + this.markdownPreview = ''; + } else { + this.markdownPreviewLoading = true; + this.$http.post( + this.markdownPreviewUrl, + { + /* + Can't use `$refs` as the component is technically in the parent component + so we access the VNode & then get the element + */ + text: this.$slots.textarea[0].elm.value, + }, + ) + .then((res) => { + const data = res.json(); + + this.markdownPreviewLoading = false; + this.markdownPreview = data.body; + + this.$nextTick(() => { + $(this.$refs['markdown-preview']).renderGFM(); + }); + }) + .catch(() => new Flash('Error loading markdown preview')); + } + }, + }, + mounted() { + /* + GLForm class handles all the toolbar buttons + */ + return new gl.GLForm($(this.$refs['gl-form']), true); + }, + }; +</script> + +<template> + <div + class="md-area prepend-top-default append-bottom-default js-vue-markdown-field" + ref="gl-form"> + <markdown-header + :preview-markdown="previewMarkdown" + @toggle-markdown="toggleMarkdownPreview" /> + <div + class="md-write-holder" + v-show="!previewMarkdown"> + <div class="zen-backdrop"> + <slot name="textarea"></slot> + <a + class="zen-control zen-control-leave js-zen-leave" + href="#" + aria-label="Enter zen mode"> + <i + class="fa fa-compress" + aria-hidden="true"> + </i> + </a> + <markdown-toolbar + :markdown-docs="markdownDocs" /> + </div> + </div> + <div + class="md md-preview-holder md-preview" + v-show="previewMarkdown"> + <div + ref="markdown-preview" + v-html="markdownPreview"> + </div> + <span v-if="markdownPreviewLoading"> + Loading... + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue new file mode 100644 index 00000000000..1a11f493b7f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -0,0 +1,113 @@ +<script> + import tooltipMixin from '../../mixins/tooltip'; + import toolbarButton from './toolbar_button.vue'; + + export default { + mixins: [ + tooltipMixin, + ], + props: { + previewMarkdown: { + type: Boolean, + required: true, + }, + }, + components: { + toolbarButton, + }, + methods: { + toggleMarkdownPreview(e, form) { + if (form && !form.find('.js-vue-markdown-field').length) { + return; + } else if (e.target.blur) { + e.target.blur(); + } + + this.$emit('toggle-markdown'); + }, + }, + mounted() { + $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview); + $(document).on('markdown-preview:hide.vue', this.toggleMarkdownPreview); + }, + beforeDestroy() { + $(document).on('markdown-preview:show.vue', this.toggleMarkdownPreview); + $(document).off('markdown-preview:hide.vue', this.toggleMarkdownPreview); + }, + }; +</script> + +<template> + <div class="md-header"> + <ul class="nav-links clearfix"> + <li :class="{ active: !previewMarkdown }"> + <a + href="#md-write-holder" + tabindex="-1" + @click.prevent="toggleMarkdownPreview($event)"> + Write + </a> + </li> + <li :class="{ active: previewMarkdown }"> + <a + href="#md-preview-holder" + tabindex="-1" + @click.prevent="toggleMarkdownPreview($event)"> + Preview + </a> + </li> + <li class="pull-right"> + <div class="toolbar-group"> + <toolbar-button + tag="**" + button-title="Add bold text" + icon="bold" /> + <toolbar-button + tag="*" + button-title="Add italic text" + icon="italic" /> + <toolbar-button + tag="> " + :prepend="true" + button-title="Insert a quote" + icon="quote-right" /> + <toolbar-button + tag="`" + tag-block="```" + button-title="Insert code" + icon="code" /> + <toolbar-button + tag="* " + :prepend="true" + button-title="Add a bullet list" + icon="list-ul" /> + <toolbar-button + tag="1. " + :prepend="true" + button-title="Add a numbered list" + icon="list-ol" /> + <toolbar-button + tag="* [ ] " + :prepend="true" + button-title="Add a task list" + icon="check-square-o" /> + </div> + <div class="toolbar-group"> + <button + aria-label="Go full screen" + class="toolbar-btn js-zen-enter" + data-container="body" + tabindex="-1" + title="Go full screen" + type="button" + ref="tooltip"> + <i + aria-hidden="true" + class="fa fa-arrows-alt fa-fw"> + </i> + </button> + </div> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue new file mode 100644 index 00000000000..93252293ba6 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -0,0 +1,33 @@ +<script> + export default { + props: { + markdownDocs: { + type: String, + required: true, + }, + }, + }; +</script> + +<template> + <div class="comment-toolbar clearfix"> + <div class="toolbar-text"> + <a + :href="markdownDocs" + target="_blank" + tabindex="-1"> + Markdown is supported + </a> + </div> + <button + class="toolbar-button markdown-selector" + type="button" + tabindex="-1"> + <i + class="fa fa-file-image-o toolbar-button-icon" + aria-hidden="true"> + </i> + Attach a file + </button> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue new file mode 100644 index 00000000000..096be507625 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue @@ -0,0 +1,58 @@ +<script> + import tooltipMixin from '../../mixins/tooltip'; + + export default { + mixins: [ + tooltipMixin, + ], + props: { + buttonTitle: { + type: String, + required: true, + }, + icon: { + type: String, + required: true, + }, + tag: { + type: String, + required: true, + }, + tagBlock: { + type: String, + required: false, + default: '', + }, + prepend: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + iconClass() { + return `fa-${this.icon}`; + }, + }, + }; +</script> + +<template> + <button + type="button" + class="toolbar-btn js-md hidden-xs" + tabindex="-1" + ref="tooltip" + data-container="body" + :data-md-tag="tag" + :data-md-block="tagBlock" + :data-md-prepend="prepend" + :title="buttonTitle" + :aria-label="buttonTitle"> + <i + aria-hidden="true" + class="fa fa-fw" + :class="iconClass"> + </i> + </button> +</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 30d16e4ed3e..f60f8eeb43d 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'; @@ -83,7 +83,7 @@ export default { } else { commitAuthorInformation = { avatar_url: this.pipeline.commit.author_gravatar_url, - web_url: `mailto:${this.pipeline.commit.author_email}`, + path: `mailto:${this.pipeline.commit.author_email}`, username: this.pipeline.commit.author_name, }; } 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 00000000000..af2b4c6786e --- /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/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue index b8db6afda12..cd6f8c7aee4 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue @@ -60,6 +60,12 @@ export default { avatarSizeClass() { return `s${this.size}`; }, + // API response sends null when gravatar is disabled and + // we provide an empty string when we use it inside user avatar link. + // In both cases we should render the defaultAvatarUrl + imageSource() { + return this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc; + }, }, }; </script> @@ -68,7 +74,7 @@ export default { <img class="avatar" :class="[avatarSizeClass, cssClasses]" - :src="imgSrc" + :src="imageSource" :width="size" :height="size" :alt="imgAlt" 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 00000000000..20f63ab663c --- /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/javascripts/vue_shared/mixins/tooltip.js b/app/assets/javascripts/vue_shared/mixins/tooltip.js index 9bb948bff66..995c0c98505 100644 --- a/app/assets/javascripts/vue_shared/mixins/tooltip.js +++ b/app/assets/javascripts/vue_shared/mixins/tooltip.js @@ -6,4 +6,8 @@ export default { updated() { $(this.$refs.tooltip).tooltip('fixTitle'); }, + + beforeDestroy() { + $(this.$refs.tooltip).tooltip('destroy'); + }, }; diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index d2ec1791d2b..b8ba77f4513 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 0db3ac1a60e..75907c35b7e 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; @@ -110,6 +110,7 @@ .award-control { margin: 0 5px 6px 0; outline: 0; + position: relative; &.disabled { cursor: default; @@ -227,8 +228,8 @@ .award-control-icon-positive, .award-control-icon-super-positive { position: absolute; - left: 11px; - bottom: 7px; + left: 10px; + bottom: 6px; opacity: 0; @include transition(opacity, transform); } diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 3dec911d289..fefe5575d9b 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 d86ae57cd9a..2d6bc17d4ff 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/files.scss b/app/assets/stylesheets/framework/files.scss index f8674b763c8..d08df05fd6c 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -66,10 +66,10 @@ &.video { background: $file-image-bg; text-align: center; + padding: 30px; img, video { - padding: 20px; max-width: 80%; } } @@ -85,7 +85,7 @@ } /** - * Blame file + * Annotate file */ &.blame { table { diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index f0994e968c8..585f4871f5f 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -90,6 +90,7 @@ .filtered-search-term { display: -webkit-flex; display: flex; + flex-shrink: 0; margin-top: 5px; margin-bottom: 5px; @@ -104,6 +105,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 +128,7 @@ .remove-token { display: inline-block; padding-left: 4px; - padding-right: 8px; + padding-right: 0; .fa-close { color: $gl-text-color-secondary; @@ -132,21 +149,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; @@ -238,7 +240,7 @@ width: 35px; background-color: $white-light; border: none; - position: absolute; + position: static; right: 0; height: 100%; outline: none; @@ -474,4 +476,5 @@ .filter-dropdown-loading { padding: 8px 16px; + text-align: center; } diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index eadb9409fee..38d884bc7eb 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -16,6 +16,22 @@ @extend .alert; @extend .alert-danger; margin: 0; + + .flash-text, + .flash-action { + display: inline-block; + } + + a.flash-action { + margin-left: 5px; + text-decoration: none; + font-weight: normal; + border-bottom: 1px solid; + + &:hover { + border-color: transparent; + } + } } .flash-notice, @@ -36,6 +52,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 d76053fe72a..49163653548 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/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index 678af978edd..0140dcf19c3 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -112,11 +112,6 @@ } } - .issue-edited-ago, - .note_edited_ago { - display: none; - } - aside:not(.right-sidebar) { display: none; } diff --git a/app/assets/stylesheets/framework/notes.scss b/app/assets/stylesheets/framework/notes.scss new file mode 100644 index 00000000000..d349e3fad9c --- /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 9ab17e67d4c..5ae833cd5f6 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 1fd734d279b..10881987038 100644 --- a/app/assets/stylesheets/framework/timeline.scss +++ b/app/assets/stylesheets/framework/timeline.scss @@ -3,9 +3,9 @@ margin: 0; padding: 0; - .note-text { - p:last-child { - margin-bottom: 0 !important; + &::before { + @include notes-media('max', $screen-xs-min) { + background: none; } } @@ -29,6 +29,16 @@ .timeline-entry-inner { position: relative; + + @include notes-media('max', $screen-xs-min) { + .timeline-icon { + display: none; + } + + .timeline-content { + margin-left: 0; + } + } } &:target, @@ -46,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 0c3407f34f8..785b09e622f 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/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 17a4e8fd83e..b3a86b92d93 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -247,7 +247,6 @@ $dark-diff-match-bg: rgba(255, 255, 255, 0.3); $dark-diff-match-color: rgba(255, 255, 255, 0.1); $file-mode-changed: #777; $file-mode-changed: #777; -$diff-image-bg: #ddd; $diff-image-info-color: grey; $diff-swipe-border: #999; $diff-view-modes-color: grey; @@ -294,7 +293,7 @@ $btn-white-active: #848484; /* * Badges */ -$badge-bg: #eee; +$badge-bg: rgba(0, 0, 0, 0.07); $badge-color: $gl-text-color-secondary; /* @@ -570,3 +569,10 @@ $filter-value-selected-color: #d7d7d7; Animation Functions */ $dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1); + +/* +Convdev Index +*/ +$color-high-score: $green-400; +$color-average-score: $orange-400; +$color-low-score: $red-400; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 68d7ab4bf84..ebe662136d5 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 14a62b6cbf0..e35558ad8e8 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 90643832390..7b4eb689f1b 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/convdev_index.scss b/app/assets/stylesheets/pages/convdev_index.scss new file mode 100644 index 00000000000..0413114c279 --- /dev/null +++ b/app/assets/stylesheets/pages/convdev_index.scss @@ -0,0 +1,255 @@ +$space-between-cards: 8px; + +.convdev-empty svg { + margin: 64px auto 32px; + max-width: 420px; +} + +.convdev-header { + margin-top: $gl-padding; + margin-bottom: $gl-padding; + padding: 0 4px; + display: flex; + align-items: center; + + .convdev-header-title { + font-size: 48px; + line-height: 1; + margin: 0; + } + + .convdev-header-subtitle { + font-size: 22px; + line-height: 1; + color: $gl-text-color-secondary; + margin-left: 8px; + font-weight: 500; + + a { + font-size: 18px; + color: $gl-text-color-secondary; + + &:hover { + color: $blue-500; + } + } + } +} + +.convdev-cards { + display: flex; + justify-content: center; + flex-wrap: wrap; +} + +.convdev-card-wrapper { + display: flex; + flex-direction: column; + align-items: stretch; + text-align: center; + width: 50%; + border-color: $border-color; + margin: 0 0 32px; + padding: $space-between-cards / 2; + position: relative; + + @media (min-width: $screen-xs-min) { + width: percentage(1 / 4); + } + + @media (min-width: $screen-sm-min) { + width: percentage(1 / 5); + } + + @media (min-width: $screen-md-min) { + width: percentage(1 / 6); + } + + @media (min-width: $screen-lg-min) { + width: percentage(1 / 10); + } +} + +.convdev-card { + border: solid 1px $border-color; + border-radius: 3px; + border-top-width: 3px; + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.convdev-card-low { + border-top-color: $color-low-score; + + .card-score-big { + background-color: $red-25; + } +} + +.convdev-card-average { + border-top-color: $color-average-score; + + .card-score-big { + background-color: $orange-25; + } +} + +.convdev-card-high { + border-top-color: $color-high-score; + + .card-score-big { + background-color: $green-25; + } +} + +.convdev-card-title { + margin: $gl-padding auto auto; + max-width: 100px; + + h3 { + font-size: 14px; + margin: 0 0 2px; + } + + .text-light { + font-size: 13px; + line-height: 1.25; + color: $gl-text-color-secondary; + } +} + +.card-scores { + display: flex; + justify-content: space-around; + align-items: center; + margin: $gl-padding $gl-btn-padding; + line-height: 1; +} + +.card-score { + color: $gl-text-color-secondary; + + .card-score-name { + font-size: 13px; + margin-top: 4px; + } +} + +.card-score-value { + font-size: 16px; + color: $gl-text-color; + font-weight: 500; +} + +.card-score-big { + border-top: 2px solid $border-color; + border-bottom: 1px solid $border-color; + font-size: 22px; + padding: 10px 0; + font-weight: 500; +} + +.card-buttons { + display: flex; + + > * { + font-size: 16px; + color: $gl-text-color-secondary; + padding: 10px; + flex-grow: 1; + + &:hover { + background-color: $border-color; + color: $gl-text-color; + } + + + * { + border-left: solid 1px $border-color; + } + } +} + +.convdev-steps { + margin-top: $gl-padding; + height: 1px; + min-width: 100%; + justify-content: space-around; + position: relative; + background: $border-color; +} + +.convdev-step { + $step-positions: 5% 10% 30% 42% 48% 55% 60% 70% 75% 90%; + @each $pos in $step-positions { + $i: index($step-positions, $pos); + + &:nth-child(#{$i}) { + left: $pos; + } + } + + position: absolute; + transform-origin: 75% 50%; + padding: 8px; + height: 50px; + width: 50px; + border-radius: 3px; + display: flex; + flex-direction: column; + align-items: center; + border: solid 1px $border-color; + background: $white-light; + transform: translate(-50%, -50%); + color: $gl-text-color-secondary; + fill: $gl-text-color-secondary; + box-shadow: 0 2px 4px $dropdown-shadow-color; + + &:hover { + padding: 8px 10px; + fill: currentColor; + z-index: 100; + height: auto; + width: auto; + + .convdev-step-title { + max-height: 2em; + opacity: 1; + transition: opacity 0.2s; + } + + svg { + transform: scale(1.5); + margin: $gl-btn-padding; + } + } + + svg { + transition: transform 0.1s; + width: 30px; + height: 30px; + min-height: 30px; + min-width: 30px; + } +} + +.convdev-step-title { + max-height: 0; + opacity: 0; + text-transform: uppercase; + margin: $gl-vert-padding 0 0; + text-align: center; + font-size: 12px; +} + +.convdev-high-score { + color: $color-high-score; +} + +.convdev-average-score { + color: $color-average-score; +} + +.convdev-low-score { + color: $color-low-score; +} diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index cfb1df4df84..b58922626fa 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; @@ -151,14 +150,10 @@ } } } - - .text-file.diff-wrap-lines table .line_holder td span { - white-space: pre-wrap; - } } .image { - background: $diff-image-bg; + background: $file-image-bg; text-align: center; padding: 30px; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 48d3b7b1d07..f269d53093d 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 d79ae47f589..c2346f2f1c3 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 bee9b13b375..702e7662527 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 8dbac76e30a..971d54e7472 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 183be86f650..2dc7f73a295 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 49e453c7dbc..0ddaab0da14 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 { @@ -111,23 +111,46 @@ margin-top: 0; text-align: center; font-size: 12px; + align-items: center; - @media (max-width: $screen-sm-max) { + @media (max-width: $screen-md-max) { // On smaller devices the warning becomes the fourth item in the list, // rather than centering, and grows to span the full width of the // comment area. order: 4; - -webkit-order: 4; + margin: 6px auto; + width: 100%; + } + + .fa { + margin-right: 8px; + } +} + +.right-sidebar-expanded { + .confidential-issue-warning { + // When the sidebar is open the warning becomes the fourth item in the list, + // rather than centering, and grows to span the full width of the + // comment area. + order: 4; margin: 6px auto; width: 100%; } } .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 51918917329..f956e3757bf 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; } } @@ -385,10 +384,16 @@ ul.notes { padding-bottom: 0; } +.note-header-author-name { + @include notes-media('max', $screen-xs-max) { + display: none; + } +} + .note-headline-light { display: inline; - @media (max-width: $screen-xs-min) { + @include notes-media('max', $screen-xs-min) { display: block; } } @@ -430,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; } @@ -441,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; @@ -455,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; } @@ -545,13 +550,13 @@ ul.notes { position: relative; top: -2px; display: inline-block; - padding-left: 4px; - padding-right: 4px; + padding-left: 7px; + padding-right: 7px; color: $notes-role-color; font-size: 12px; line-height: 20px; border: 1px solid $border-color; - border-radius: $border-radius-base; + border-radius: $label-border-radius; } @@ -590,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; + } } } } @@ -607,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; @@ -625,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; } @@ -667,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; @@ -680,6 +684,10 @@ ul.notes { .line-resolve-btn { margin-right: 5px; + + svg { + vertical-align: middle; + } } } @@ -716,6 +724,10 @@ ul.notes { } } +.line-resolve-text { + vertical-align: middle; +} + .discussion-next-btn { svg { margin: 0; @@ -732,11 +744,6 @@ ul.notes { // Merge request notes in diffs .diff-file { - // Diff is side by side - .notes_content.parallel .note-header .note-headline-light { - display: block; - position: relative; - } // 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 292584eba28..58b458cd837 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; } @@ -980,3 +984,11 @@ width: 12px; } } + +.pipeline-header-container { + min-height: 55px; + + .text-center { + padding-top: 12px; + } +} diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index fe084eb9397..c207159f606 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -287,6 +287,7 @@ table.u2f-registrations { .user-callout { margin: 0 auto; + max-width: $screen-lg-min; .bordered-box { border: 1px solid $blue-300; @@ -295,14 +296,15 @@ table.u2f-registrations { position: relative; display: flex; justify-content: center; + align-items: center; } .landing { - margin-top: $gl-padding; - margin-bottom: $gl-padding; + padding: 32px; .close { position: absolute; + top: 20px; right: 20px; opacity: 1; @@ -330,11 +332,20 @@ table.u2f-registrations { height: 110px; vertical-align: top; } + + &.convdev { + margin: 0 0 0 30px; + + svg { + height: 127px; + } + } } .user-callout-copy { display: inline-block; vertical-align: top; + max-width: 570px; } } @@ -348,12 +359,20 @@ table.u2f-registrations { .landing { .svg-container, .user-callout-copy { - margin: 0; + margin: 0 auto; display: block; svg { height: 75px; } + + &.convdev { + margin: $gl-padding auto 0; + + svg { + height: 120px; + } + } } } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index f0bf3d4c267..24ab2bedea2 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/admin/conversational_development_index_controller.rb b/app/controllers/admin/conversational_development_index_controller.rb new file mode 100644 index 00000000000..921169d3e2b --- /dev/null +++ b/app/controllers/admin/conversational_development_index_controller.rb @@ -0,0 +1,5 @@ +class Admin::ConversationalDevelopmentIndexController < Admin::ApplicationController + def show + @metric = ConversationalDevelopmentIndex::Metric.order(:created_at).last&.present + end +end diff --git a/app/controllers/admin/hook_logs_controller.rb b/app/controllers/admin/hook_logs_controller.rb new file mode 100644 index 00000000000..aa069b89563 --- /dev/null +++ b/app/controllers/admin/hook_logs_controller.rb @@ -0,0 +1,29 @@ +class Admin::HookLogsController < Admin::ApplicationController + include HooksExecution + + before_action :hook, only: [:show, :retry] + before_action :hook_log, only: [:show, :retry] + + respond_to :html + + def show + end + + def retry + status, message = hook.execute(hook_log.request_data, hook_log.trigger) + + set_hook_execution_notice(status, message) + + redirect_to edit_admin_hook_path(@hook) + end + + private + + def hook + @hook ||= SystemHook.find(params[:hook_id]) + end + + def hook_log + @hook_log ||= hook.web_hook_logs.find(params[:id]) + end +end diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb index ccfe553c89e..b9251e140f8 100644 --- a/app/controllers/admin/hooks_controller.rb +++ b/app/controllers/admin/hooks_controller.rb @@ -1,5 +1,7 @@ class Admin::HooksController < Admin::ApplicationController - before_action :hook, only: :edit + include HooksExecution + + before_action :hook_logs, only: :edit def index @hooks = SystemHook.all @@ -36,15 +38,9 @@ class Admin::HooksController < Admin::ApplicationController end def test - data = { - event_name: "project_create", - name: "Ruby", - path: "ruby", - project_id: 1, - owner_name: "Someone", - owner_email: "example@gitlabhq.com" - } - hook.execute(data, 'system_hooks') + status, message = hook.execute(sample_hook_data, 'system_hooks') + + set_hook_execution_notice(status, message) redirect_back_or_default end @@ -55,6 +51,11 @@ class Admin::HooksController < Admin::ApplicationController @hook ||= SystemHook.find(params[:id]) end + def hook_logs + @hook_logs ||= + Kaminari.paginate_array(hook.web_hook_logs.order(created_at: :desc)).page(params[:page]) + end + def hook_params params.require(:hook).permit( :enable_ssl_verification, @@ -65,4 +66,15 @@ class Admin::HooksController < Admin::ApplicationController :url ) end + + def sample_hook_data + { + event_name: "project_create", + name: "Ruby", + path: "ruby", + project_id: 1, + owner_name: "Someone", + owner_email: "example@gitlabhq.com" + } + end end diff --git a/app/controllers/admin/builds_controller.rb b/app/controllers/admin/jobs_controller.rb index 88f3c0e2fd4..5162273ef8a 100644 --- a/app/controllers/admin/builds_controller.rb +++ b/app/controllers/admin/jobs_controller.rb @@ -1,4 +1,4 @@ -class Admin::BuildsController < Admin::ApplicationController +class Admin::JobsController < Admin::ApplicationController def index @scope = params[:scope] @all_builds = Ci::Build @@ -20,6 +20,6 @@ class Admin::BuildsController < Admin::ApplicationController def cancel_all Ci::Build.running_or_pending.each(&:cancel) - redirect_to admin_builds_path + redirect_to admin_jobs_path end end diff --git a/app/controllers/admin/keys_controller.rb b/app/controllers/admin/keys_controller.rb index 054bb52b696..299419fb509 100644 --- a/app/controllers/admin/keys_controller.rb +++ b/app/controllers/admin/keys_controller.rb @@ -15,9 +15,9 @@ class Admin::KeysController < Admin::ApplicationController respond_to do |format| if key.destroy - format.html { redirect_to [:admin, user], notice: 'User key was successfully removed.' } + format.html { redirect_to keys_admin_user_path(user), notice: 'User key was successfully removed.' } else - format.html { redirect_to [:admin, user], alert: 'Failed to remove user key.' } + format.html { redirect_to keys_admin_user_path(user), alert: 'Failed to remove user key.' } end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ab5aed24917..47ce21d238b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -283,12 +283,8 @@ class ApplicationController < ActionController::Base request.base_url end - def set_locale - Gitlab::I18n.set_locale(current_user) - - yield - ensure - Gitlab::I18n.reset_locale + def set_locale(&block) + Gitlab::I18n.with_user_locale(current_user, &block) end def sessionless_sign_in(user) diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index e2f5aa8508e..907717dcb96 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/concerns/diff_for_path.rb b/app/controllers/concerns/diff_for_path.rb index 1efa9fe060f..d5388c4cd20 100644 --- a/app/controllers/concerns/diff_for_path.rb +++ b/app/controllers/concerns/diff_for_path.rb @@ -8,17 +8,6 @@ module DiffForPath return render_404 unless diff_file - diff_commit = commit_for_diff(diff_file) - blob = diff_file.blob(diff_commit) - - locals = { - diff_file: diff_file, - diff_commit: diff_commit, - diff_refs: diffs.diff_refs, - blob: blob, - project: project - } - - render json: { html: view_to_html_string('projects/diffs/_content', locals) } + render json: { html: view_to_html_string('projects/diffs/_content', diff_file: diff_file) } end end diff --git a/app/controllers/concerns/hooks_execution.rb b/app/controllers/concerns/hooks_execution.rb new file mode 100644 index 00000000000..846cd60518f --- /dev/null +++ b/app/controllers/concerns/hooks_execution.rb @@ -0,0 +1,15 @@ +module HooksExecution + extend ActiveSupport::Concern + + private + + def set_hook_execution_notice(status, message) + if status && status >= 200 && status < 400 + flash[:notice] = "Hook executed successfully: HTTP #{status}" + elsif status + flash[:alert] = "Hook executed successfully but returned HTTP #{status} #{message}" + else + flash[:alert] = "Hook execution failed: #{message}" + end + end +end diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 4cf645d6341..0c3b68a7ac3 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -14,7 +14,16 @@ module IssuableActions name = issuable.human_class_name flash[:notice] = "The #{name} was successfully deleted." - redirect_to polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class]) + index_path = polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class]) + + respond_to do |format| + format.html { redirect_to index_path } + format.json do + render json: { + web_url: index_path + } + end + end end def bulk_update diff --git a/app/controllers/concerns/renders_blob.rb b/app/controllers/concerns/renders_blob.rb index 1d37e4cb3bd..54dcd7c61ce 100644 --- a/app/controllers/concerns/renders_blob.rb +++ b/app/controllers/concerns/renders_blob.rb @@ -18,7 +18,7 @@ module RendersBlob } end - def override_max_blob_size(blob) - blob.override_max_size! if params[:override_max_size] == 'true' + def conditionally_expand_blob(blob) + blob.expand! if params[:expanded] == 'true' end end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 5a1efcab1a3..3d49ea97591 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 79d420a32d3..f9c31920302 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 @@ -26,7 +24,7 @@ class DashboardController < Dashboard::ApplicationController def load_events projects = if params[:filter] == "starred" - current_user.viewable_starred_projects + ProjectsFinder.new(current_user: current_user, params: { starred: true }).execute else current_user.authorized_projects end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 965ced4d372..18a2d69db29 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/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 1224e9503c9..ea036b1f705 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -27,7 +27,7 @@ class Projects::ArtifactsController < Projects::ApplicationController def file blob = @entry.blob - override_max_blob_size(blob) + conditionally_expand_blob(blob) respond_to do |format| format.html do @@ -46,7 +46,7 @@ class Projects::ArtifactsController < Projects::ApplicationController def keep build.keep_artifacts! - redirect_to namespace_project_build_path(project.namespace, project, build) + redirect_to namespace_project_job_path(project.namespace, project, build) end def latest_succeeded @@ -79,7 +79,7 @@ class Projects::ArtifactsController < Projects::ApplicationController end def build_from_id - project.builds.find_by(id: params[:build_id]) if params[:build_id] + project.builds.find_by(id: params[:job_id]) if params[:job_id] end def build_from_ref diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 87721fbe2f5..7025c7a1de6 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -35,7 +35,7 @@ class Projects::BlobController < Projects::ApplicationController end def show - override_max_blob_size(@blob) + conditionally_expand_blob(@blob) respond_to do |format| format.html do diff --git a/app/controllers/projects/build_artifacts_controller.rb b/app/controllers/projects/build_artifacts_controller.rb new file mode 100644 index 00000000000..f34a198634e --- /dev/null +++ b/app/controllers/projects/build_artifacts_controller.rb @@ -0,0 +1,55 @@ +class Projects::BuildArtifactsController < Projects::ApplicationController + include ExtractsPath + include RendersBlob + + before_action :authorize_read_build! + before_action :extract_ref_name_and_path + before_action :validate_artifacts! + + def download + redirect_to download_namespace_project_job_artifacts_path(project.namespace, project, job) + end + + def browse + redirect_to browse_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path]) + end + + def file + redirect_to file_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path]) + end + + def raw + redirect_to raw_namespace_project_job_artifacts_path(project.namespace, project, job, path: params[:path]) + end + + def latest_succeeded + redirect_to latest_succeeded_namespace_project_artifacts_path(project.namespace, project, job, ref_name_and_path: params[:ref_name_and_path], job: params[:job]) + end + + private + + def validate_artifacts! + render_404 unless job && job.artifacts? + end + + def extract_ref_name_and_path + return unless params[:ref_name_and_path] + + @ref_name, @path = extract_ref(params[:ref_name_and_path]) + end + + def job + @job ||= job_from_id || job_from_ref + end + + def job_from_id + project.builds.find_by(id: params[:build_id]) if params[:build_id] + end + + def job_from_ref + return unless @ref_name + + jobs = project.latest_successful_builds_for(@ref_name) + jobs.find_by(name: params[:job]) + end +end diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index dfaaea71b9c..1334a231788 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -1,131 +1,21 @@ class Projects::BuildsController < Projects::ApplicationController - before_action :build, except: [:index, :cancel_all] - - before_action :authorize_read_build!, - only: [:index, :show, :status, :raw, :trace] - before_action :authorize_update_build!, - except: [:index, :show, :status, :raw, :trace, :cancel_all] - - layout 'project' + before_action :authorize_read_build! def index - @scope = params[:scope] - @all_builds = project.builds.relevant - @builds = @all_builds.order('created_at DESC') - @builds = - case @scope - when 'pending' - @builds.pending.reverse_order - when 'running' - @builds.running.reverse_order - when 'finished' - @builds.finished - else - @builds - end - @builds = @builds.includes([ - { pipeline: :project }, - :project, - :tags - ]) - @builds = @builds.page(params[:page]).per(30) - end - - def cancel_all - return access_denied! unless can?(current_user, :update_build, project) - - @project.builds.running_or_pending.each do |build| - build.cancel if can?(current_user, :update_build, build) - end - - redirect_to namespace_project_builds_path(project.namespace, project) + redirect_to namespace_project_jobs_path(project.namespace, project) end def show - @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC') - @builds = @builds.where("id not in (?)", @build.id) - @pipeline = @build.pipeline - end - - def trace - build.trace.read do |stream| - respond_to do |format| - format.json do - result = { - id: @build.id, status: @build.status, complete: @build.complete? - } - - if stream.valid? - stream.limit - state = params[:state].presence - trace = stream.html_with_state(state) - result.merge!(trace.to_h) - end - - render json: result - end - end - end - end - - def retry - return respond_422 unless @build.retryable? - - build = Ci::Build.retry(@build, current_user) - redirect_to build_path(build) - end - - def play - return respond_422 unless @build.playable? - - build = @build.play(current_user) - redirect_to build_path(build) - end - - def cancel - return respond_422 unless @build.cancelable? - - @build.cancel - redirect_to build_path(@build) - end - - def status - render json: BuildSerializer - .new(project: @project, current_user: @current_user) - .represent_status(@build) - end - - def erase - if @build.erase(erased_by: current_user) - redirect_to namespace_project_build_path(project.namespace, project, @build), - notice: "Build has been successfully erased!" - else - respond_422 - end + redirect_to namespace_project_job_path(project.namespace, project, job) end def raw - build.trace.read do |stream| - if stream.file? - send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' - else - render_404 - end - end + redirect_to raw_namespace_project_job_path(project.namespace, project, job) end private - def authorize_update_build! - return access_denied! unless can?(current_user, :update_build, build) - end - - def build - @build ||= project.builds.find(params[:id]) - .present(current_user: current_user) - end - - def build_path(build) - namespace_project_build_path(build.project.namespace, build.project, build) + def job + @job ||= project.builds.find(params[:id]) end end diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 008d2f5815f..88dd600e5fe 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -51,13 +51,9 @@ class Projects::CompareController < Projects::ApplicationController if @compare @commits = @compare.commits - @start_commit = @compare.start_commit - @commit = @compare.commit - @base_commit = @compare.base_commit - @diffs = @compare.diffs(diff_options) - environment_params = @repository.branch_exists?(@head_ref) ? { ref: @head_ref } : { commit: @commit } + environment_params = @repository.branch_exists?(@head_ref) ? { ref: @head_ref } : { commit: @compare.commit } @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last @diff_notes_disabled = true diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index efe83776834..4630f451445 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -15,6 +15,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController respond_to do |format| format.html format.json do + Gitlab::PollingInterval.set_header(response, interval: 3_000) + render json: { environments: EnvironmentSerializer .new(project: @project, current_user: @current_user) diff --git a/app/controllers/projects/hook_logs_controller.rb b/app/controllers/projects/hook_logs_controller.rb new file mode 100644 index 00000000000..354f0d6db3a --- /dev/null +++ b/app/controllers/projects/hook_logs_controller.rb @@ -0,0 +1,33 @@ +class Projects::HookLogsController < Projects::ApplicationController + include HooksExecution + + before_action :authorize_admin_project! + + before_action :hook, only: [:show, :retry] + before_action :hook_log, only: [:show, :retry] + + respond_to :html + + layout 'project_settings' + + def show + end + + def retry + status, message = hook.execute(hook_log.request_data, hook_log.trigger) + + set_hook_execution_notice(status, message) + + redirect_to edit_namespace_project_hook_path(@project.namespace, @project, @hook) + end + + private + + def hook + @hook ||= @project.hooks.find(params[:hook_id]) + end + + def hook_log + @hook_log ||= hook.web_hook_logs.find(params[:id]) + end +end diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb index 86d13a0d222..38bd82841dc 100644 --- a/app/controllers/projects/hooks_controller.rb +++ b/app/controllers/projects/hooks_controller.rb @@ -1,7 +1,9 @@ class Projects::HooksController < Projects::ApplicationController + include HooksExecution + # Authorize before_action :authorize_admin_project! - before_action :hook, only: :edit + before_action :hook_logs, only: :edit respond_to :html @@ -34,13 +36,7 @@ class Projects::HooksController < Projects::ApplicationController if !@project.empty_repo? status, message = TestHookService.new.execute(hook, current_user) - if status && status >= 200 && status < 400 - flash[:notice] = "Hook executed successfully: HTTP #{status}" - elsif status - flash[:alert] = "Hook executed successfully but returned HTTP #{status} #{message}" - else - flash[:alert] = "Hook execution failed: #{message}" - end + set_hook_execution_notice(status, message) else flash[:alert] = 'Hook execution failed. Ensure the project has commits.' end @@ -60,6 +56,11 @@ class Projects::HooksController < Projects::ApplicationController @hook ||= @project.hooks.find(params[:id]) end + def hook_logs + @hook_logs ||= + Kaminari.paginate_array(hook.web_hook_logs.order(created_at: :desc)).page(params[:page]) + end + def hook_params params.require(:hook).permit( :job_events, diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index cbef8fa94d4..8b1efd0c572 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -148,10 +148,7 @@ class Projects::IssuesController < Projects::ApplicationController format.json do if @issue.valid? - render json: @issue.to_json(methods: [:task_status, :task_status_short], - include: { milestone: {}, - assignees: { only: [:id, :name, :username], methods: [:avatar_url] }, - labels: { methods: :text_color } }) + render json: IssueSerializer.new.represent(@issue) else render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity end @@ -202,14 +199,21 @@ class Projects::IssuesController < Projects::ApplicationController def realtime_changes Gitlab::PollingInterval.set_header(response, interval: 3_000) - render json: { + response = { title: view_context.markdown_field(@issue, :title), title_text: @issue.title, description: view_context.markdown_field(@issue, :description), description_text: @issue.description, - task_status: @issue.task_status, - updated_at: @issue.updated_at + task_status: @issue.task_status } + + if @issue.is_edited? + response[:updated_at] = @issue.updated_at + response[:updated_by_name] = @issue.last_edited_by.name + response[:updated_by_path] = user_path(@issue.last_edited_by) + end + + render json: response end def create_merge_request diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb new file mode 100644 index 00000000000..cb4f46388fd --- /dev/null +++ b/app/controllers/projects/jobs_controller.rb @@ -0,0 +1,142 @@ +class Projects::JobsController < Projects::ApplicationController + before_action :build, except: [:index, :cancel_all] + + before_action :authorize_read_build!, + only: [:index, :show, :status, :raw, :trace] + before_action :authorize_update_build!, + except: [:index, :show, :status, :raw, :trace, :cancel_all] + + layout 'project' + + def index + @scope = params[:scope] + @all_builds = project.builds.relevant + @builds = @all_builds.order('created_at DESC') + @builds = + case @scope + when 'pending' + @builds.pending.reverse_order + when 'running' + @builds.running.reverse_order + when 'finished' + @builds.finished + else + @builds + end + @builds = @builds.includes([ + { pipeline: :project }, + :project, + :tags + ]) + @builds = @builds.page(params[:page]).per(30) + end + + def cancel_all + return access_denied! unless can?(current_user, :update_build, project) + + @project.builds.running_or_pending.each do |build| + build.cancel if can?(current_user, :update_build, build) + end + + redirect_to namespace_project_jobs_path(project.namespace, project) + end + + def show + @builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC') + @builds = @builds.where("id not in (?)", @build.id) + @pipeline = @build.pipeline + + respond_to do |format| + format.html + format.json do + Gitlab::PollingInterval.set_header(response, interval: 10_000) + + render json: BuildSerializer + .new(project: @project, current_user: @current_user) + .represent(@build, {}, BuildDetailsEntity) + end + end + end + + def trace + build.trace.read do |stream| + respond_to do |format| + format.json do + result = { + id: @build.id, status: @build.status, complete: @build.complete? + } + + if stream.valid? + stream.limit + state = params[:state].presence + trace = stream.html_with_state(state) + result.merge!(trace.to_h) + end + + render json: result + end + end + end + end + + def retry + return respond_422 unless @build.retryable? + + build = Ci::Build.retry(@build, current_user) + redirect_to build_path(build) + end + + def play + return respond_422 unless @build.playable? + + build = @build.play(current_user) + redirect_to build_path(build) + end + + def cancel + return respond_422 unless @build.cancelable? + + @build.cancel + redirect_to build_path(@build) + end + + def status + render json: BuildSerializer + .new(project: @project, current_user: @current_user) + .represent_status(@build) + end + + def erase + if @build.erase(erased_by: current_user) + redirect_to namespace_project_job_path(project.namespace, project, @build), + notice: "Build has been successfully erased!" + else + respond_422 + end + end + + def raw + build.trace.read do |stream| + if stream.file? + send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline' + else + render_404 + end + end + end + + private + + def authorize_update_build! + return access_denied! unless can?(current_user, :update_build, build) + end + + def build + @build ||= project.builds.find(params[:id]) + .present(current_user: current_user) + end + + def build_path(build) + namespace_project_job_path(build.project.namespace, build.project, build) + end +end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 0352065998b..314906b5f09 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -14,7 +14,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController ] before_action :validates_merge_request, only: [:show, :diffs, :commits, :pipelines] before_action :define_show_vars, only: [:diffs, :commits, :conflicts, :conflict_for_path, :builds, :pipelines] - before_action :define_commit_vars, only: [:diffs] before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :conflict_for_path, :pipelines] before_action :close_merge_request_without_source_project, only: [:show, :diffs, :commits, :builds, :pipelines] before_action :check_if_can_be_merged, only: :show @@ -130,8 +129,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController @diff_notes_disabled = true end - define_commit_vars - render_diff_for_path(@diffs) end @@ -500,11 +497,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes)) end - def define_commit_vars - @commit = @merge_request.diff_head_commit - @base_commit = @merge_request.diff_base_commit || @merge_request.likely_diff_base_commit - end - def define_diff_vars @merge_request_diff = if params[:diff_id] @@ -569,7 +561,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController @source_project = merge_request.source_project @commits = @merge_request.compare_commits.reverse @commit = @merge_request.diff_head_commit - @base_commit = @merge_request.diff_base_commit @note_counts = Note.where(commit_id: @commits.map(&:id)). group(:commit_id).count diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 602d3dd8c1c..87ec0df257a 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -58,7 +58,7 @@ class Projects::PipelinesController < Projects::ApplicationController def create @pipeline = Ci::CreatePipelineService .new(project, current_user, create_params) - .execute(ignore_skip_ci: true, save_on_errors: false) + .execute(:web, ignore_skip_ci: true, save_on_errors: false) if @pipeline.persisted? redirect_to namespace_project_pipeline_path(project.namespace, project, @pipeline) diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb index 667f4870c7a..2a0b58fae7c 100644 --- a/app/controllers/projects/refs_controller.rb +++ b/app/controllers/projects/refs_controller.rb @@ -74,6 +74,6 @@ class Projects::RefsController < Projects::ApplicationController private def validate_ref_id - return not_found! if params[:id].present? && params[:id] !~ Gitlab::Regex.git_reference_regex + return not_found! if params[:id].present? && params[:id] !~ Gitlab::PathRegex.git_reference_regex end end diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index f9d798d0455..704f8cc8a79 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -4,6 +4,7 @@ class Projects::ServicesController < Projects::ApplicationController # Authorize before_action :authorize_admin_project! before_action :service, only: [:edit, :update, :test] + before_action :update_service, only: [:update, :test] respond_to :html @@ -13,36 +14,46 @@ class Projects::ServicesController < Projects::ApplicationController end def update - @service.assign_attributes(service_params[:service]) if @service.save(context: :manual_change) - redirect_to( - edit_namespace_project_service_path(@project.namespace, @project, @service.to_param), - notice: 'Successfully updated.' - ) + redirect_to(namespace_project_settings_integrations_path(@project.namespace, @project), notice: success_message) else render 'edit' end end def test - return render_404 unless @service.can_test? + message = {} + + if @service.can_test? + data = @service.test_data(project, current_user) + outcome = @service.test(data) - data = @service.test_data(project, current_user) - outcome = @service.test(data) + unless outcome[:success] + message = { error: true, message: 'Test failed.', service_response: outcome[:result].to_s } + end - if outcome[:success] - message = { notice: 'We sent a request to the provided URL' } + status = :ok else - error_message = "We tried to send a request to the provided URL but an error occurred" - error_message << ": #{outcome[:result]}" if outcome[:result].present? - message = { alert: error_message } + status = :not_found end - redirect_back_or_default(options: message) + render json: message, status: status end private + def success_message + if @service.active? + "#{@service.title} activated." + else + "#{@service.title} settings saved, but not activated." + end + end + + def update_service + @service.assign_attributes(service_params[:service]) + end + def service @service ||= @project.find_or_initialize_service(params[:id]) end diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index 3b2b0d9e502..3a97c1e98af 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -56,7 +56,7 @@ class Projects::SnippetsController < Projects::ApplicationController def show blob = @snippet.blob - override_max_blob_size(blob) + conditionally_expand_blob(blob) respond_to do |format| format.html do diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index a4d1b1ee69b..0953eecaeb5 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -42,6 +42,7 @@ class Projects::VariablesController < Projects::ApplicationController private def project_params - params.require(:variable).permit([:id, :key, :value, :_destroy]) + params.require(:variable) + .permit([:id, :key, :value, :protected, :_destroy]) end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 8c6ba4915cd..10806895764 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -90,7 +90,7 @@ class SessionsController < Devise::SessionsController # Prevent a 'you are already signed in' message directly after signing: # we should never redirect to '/users/sign_in' after signing in successfully. - unless redirect_path == new_user_session_path + unless URI(redirect_path).path == new_user_session_path store_location_for(:redirect, redirect_path) end end @@ -103,6 +103,10 @@ class SessionsController < Devise::SessionsController provider = Gitlab.config.omniauth.auto_sign_in_with_provider return unless provider.present? + # If a "auto_sign_in" query parameter is set to a falsy value, don't auto sign-in. + # Otherwise, the default is to auto sign-in. + return if Gitlab::Utils.to_boolean(params[:auto_sign_in]) == false + # Auto sign in with an Omniauth provider only if the standard "you need to sign-in" alert is # registered or no alert at all. In case of another alert (such as a blocked user), it is safer # to do nothing to prevent redirection loops with certain Omniauth providers. diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index 7445f61195d..5b2d143ee79 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -58,7 +58,7 @@ class SnippetsController < ApplicationController def show blob = @snippet.blob - override_max_blob_size(blob) + conditionally_expand_blob(blob) @note = Note.new(noteable: @snippet) @noteable = @snippet diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index f6d8226bf3f..5bf722d1ec6 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -7,6 +7,7 @@ # project_ids_relation: int[] - project ids to use # params: # trending: boolean +# owned: boolean # non_public: boolean # starred: boolean # sort: string @@ -28,13 +29,17 @@ class ProjectsFinder < UnionFinder def execute items = init_collection - items = by_ids(items) + items = items.map do |item| + item = by_ids(item) + item = by_personal(item) + item = by_starred(item) + item = by_trending(item) + item = by_visibilty_level(item) + item = by_tags(item) + item = by_search(item) + by_archived(item) + end items = union(items) - items = by_personal(items) - items = by_visibilty_level(items) - items = by_tags(items) - items = by_search(items) - items = by_archived(items) sort(items) end @@ -43,10 +48,8 @@ class ProjectsFinder < UnionFinder def init_collection projects = [] - if params[:trending].present? - projects << Project.trending - elsif params[:starred].present? && current_user - projects << current_user.viewable_starred_projects + if params[:owned].present? + projects << current_user.owned_projects if current_user else projects << current_user.authorized_projects if current_user projects << Project.unscoped.public_to_user(current_user) unless params[:non_public].present? @@ -56,7 +59,7 @@ class ProjectsFinder < UnionFinder end def by_ids(items) - project_ids_relation ? items.map { |item| item.where(id: project_ids_relation) } : items + project_ids_relation ? items.where(id: project_ids_relation) : items end def union(items) @@ -67,6 +70,14 @@ class ProjectsFinder < UnionFinder (params[:personal].present? && current_user) ? items.personal(current_user) : items end + def by_starred(items) + (params[:starred].present? && current_user) ? items.starred_by(current_user) : items + end + + def by_trending(items) + params[:trending].present? ? items.trending : items + end + def by_visibilty_level(items) params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index dc13386184e..c358f23f541 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -39,7 +39,7 @@ class TodosFinder private def action_id? - action_id.present? && Todo::ACTION_NAMES.has_key?(action_id.to_i) + action_id.present? && Todo::ACTION_NAMES.key?(action_id.to_i) end def action_id diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e5e64650708..71154da7ec5 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -181,7 +181,7 @@ module ApplicationHelper end def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', exclude_author: false) - return if object.last_edited_at == object.created_at || object.last_edited_at.blank? + return unless object.is_edited? content_tag :small, class: 'edited-text' do output = content_tag(:span, 'Edited ') @@ -275,8 +275,8 @@ module ApplicationHelper 'active' if condition end - def show_user_callout? - cookies[:user_callout_dismissed] == 'true' + def show_callout?(name) + cookies[name] != 'true' end def linkedin_url(user) diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index b7e0ff8ecd0..bbe7f3c8fb4 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -8,18 +8,28 @@ module AvatarsHelper })) end - def user_avatar(options = {}) + def user_avatar_without_link(options = {}) avatar_size = options[:size] || 16 user_name = options[:user].try(:name) || options[:user_name] css_class = options[:css_class] || '' - - avatar = image_tag( - avatar_icon(options[:user] || options[:user_email], avatar_size), + avatar_url = options[:url] || avatar_icon(options[:user] || options[:user_email], avatar_size) + data_attributes = { container: 'body' } + + if options[:lazy] + data_attributes[:src] = avatar_url + end + + image_tag( + options[:lazy] ? '' : avatar_url, class: "avatar has-tooltip s#{avatar_size} #{css_class}", alt: "#{user_name}'s avatar", title: user_name, - data: { container: 'body' } + data: data_attributes ) + end + + def user_avatar(options = {}) + avatar = user_avatar_without_link(options) if options[:user] link_to(avatar, user_path(options[:user])) diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 622e14e21ff..3efa7c36057 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -120,7 +120,7 @@ module BlobHelper def blob_raw_url if @build && @entry - raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path) + raw_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: @entry.path) elsif @snippet if @snippet.project_id raw_namespace_project_snippet_path(@project.namespace, @project, @snippet) @@ -240,14 +240,10 @@ module BlobHelper def blob_render_error_reason(viewer) case viewer.render_error + when :collapsed + "it is larger than #{number_to_human_size(viewer.collapse_limit)}" when :too_large - max_size = - if viewer.can_override_max_size? - viewer.overridable_max_size - else - viewer.max_size - end - "it is larger than #{number_to_human_size(max_size)}" + "it is larger than #{number_to_human_size(viewer.size_limit)}" when :server_side_but_stored_externally case viewer.blob.external_storage when :lfs @@ -264,8 +260,8 @@ module BlobHelper error = viewer.render_error options = [] - if error == :too_large && viewer.can_override_max_size? - options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, override_max_size: true, format: nil))) + if error == :collapsed + options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, expanded: true, format: nil))) end # If the error is `:server_side_but_stored_externally`, the simple viewer will show the same error, diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index 2eb2c6c7389..f0a0d245dc0 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -2,7 +2,7 @@ module BuildsHelper def build_summary(build, skip: false) if build.has_trace? if skip - link_to "View job trace", pipeline_build_url(build.pipeline, build) + link_to "View job trace", pipeline_job_url(build.pipeline, build) else build.trace.html(last_lines: 10).html_safe end @@ -20,8 +20,8 @@ module BuildsHelper def javascript_build_options { - page_url: namespace_project_build_url(@project.namespace, @project, @build), - build_url: namespace_project_build_url(@project.namespace, @project, @build, :json), + page_url: namespace_project_job_url(@project.namespace, @project, @build), + build_url: namespace_project_job_url(@project.namespace, @project, @build, :json), build_status: @build.status, build_stage: @build.stage, log_state: '' @@ -31,7 +31,7 @@ module BuildsHelper def build_failed_issue_options { title: "Build Failed ##{@build.id}", - description: namespace_project_build_url(@project.namespace, @project, @build) + description: namespace_project_job_url(@project.namespace, @project, @build) } end end diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 206d0753f08..0081bbd92b3 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/commits_helper.rb b/app/helpers/commits_helper.rb index d59d51905a6..5b5cdebe919 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -15,16 +15,6 @@ module CommitsHelper commit_person_link(commit, options.merge(source: :committer)) end - def image_diff_class(diff) - if diff.deleted_file - "deleted" - elsif diff.new_file - "added" - else - nil - end - end - def commit_to_html(commit, ref, project) render 'projects/commits/commit', commit: commit, diff --git a/app/helpers/conversational_development_index_helper.rb b/app/helpers/conversational_development_index_helper.rb new file mode 100644 index 00000000000..1ff54415811 --- /dev/null +++ b/app/helpers/conversational_development_index_helper.rb @@ -0,0 +1,16 @@ +module ConversationalDevelopmentIndexHelper + def score_level(score) + if score < 33.33 + 'low' + elsif score < 66.66 + 'average' + else + 'high' + end + end + + def format_score(score) + precision = score < 1 ? 2 : 1 + number_with_precision(score, precision: precision) + end +end diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index 4a06ee653ee..2ae3a616933 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -8,8 +8,8 @@ module DiffHelper [marked_old_line, marked_new_line] end - def expand_all_diffs? - params[:expand_all_diffs].present? + def diffs_expanded? + params[:expanded].present? end def diff_view @@ -22,10 +22,10 @@ module DiffHelper end def diff_options - options = { ignore_whitespace_change: hide_whitespace?, no_collapse: expand_all_diffs? } + options = { ignore_whitespace_change: hide_whitespace?, expanded: diffs_expanded? } if action_name == 'diff_for_path' - options[:no_collapse] = true + options[:expanded] = true options[:paths] = params.values_at(:old_path, :new_path) end @@ -66,12 +66,12 @@ module DiffHelper discussions_left = discussions_right = nil - if left && (left.unchanged? || left.removed?) + if left && (left.unchanged? || left.discussable?) line_code = diff_file.line_code(left) discussions_left = @grouped_diff_discussions[line_code] end - if right && right.added? + if right&.discussable? line_code = diff_file.line_code(right) discussions_right = @grouped_diff_discussions[line_code] end @@ -102,14 +102,14 @@ module DiffHelper ].join(' ').html_safe end - def commit_for_diff(diff_file) - return diff_file.content_commit if diff_file.content_commit + def diff_file_blob_raw_path(diff_file) + namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.content_sha, diff_file.file_path)) + end - if diff_file.deleted_file - @base_commit || @commit.parent || @commit - else - @commit - end + def diff_file_old_blob_raw_path(diff_file) + sha = diff_file.old_content_sha + return unless sha + namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.old_content_sha, diff_file.old_path)) end def diff_file_html_data(project, diff_file_path, diff_commit_id) @@ -120,8 +120,8 @@ module DiffHelper } end - def editable_diff?(diff) - !diff.deleted_file && @merge_request && @merge_request.source_project + def editable_diff?(diff_file) + !diff_file.deleted_file? && @merge_request && @merge_request.source_project end private diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 8ed99642c7a..ac8c518ac84 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -1,27 +1,27 @@ module DropdownsHelper def dropdown_tag(toggle_text, options: {}, &block) - content_tag :div, class: "dropdown #{options[:wrapper_class] if options.has_key?(:wrapper_class)}" do + content_tag :div, class: "dropdown #{options[:wrapper_class] if options.key?(:wrapper_class)}" do data_attr = { toggle: "dropdown" } - if options.has_key?(:data) + if options.key?(:data) data_attr = options[:data].merge(data_attr) end dropdown_output = dropdown_toggle(toggle_text, data_attr, options) - dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.has_key?(:dropdown_class)}") do + dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}") do output = "" - if options.has_key?(:title) + if options.key?(:title) output << dropdown_title(options[:title]) end - if options.has_key?(:filter) + if options.key?(:filter) output << dropdown_filter(options[:placeholder]) end - output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.has_key?(:content_class)}") do - capture(&block) if block && !options.has_key?(:footer_content) + output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.key?(:content_class)}") do + capture(&block) if block && !options.key?(:footer_content) end if block && options[:footer_content] @@ -41,7 +41,7 @@ module DropdownsHelper def dropdown_toggle(toggle_text, data_attr, options = {}) default_label = data_attr[:default_label] - content_tag(:button, class: "dropdown-menu-toggle #{options[:toggle_class] if options.has_key?(:toggle_class)}", id: (options[:id] if options.has_key?(:id)), type: "button", data: data_attr) do + content_tag(:button, class: "dropdown-menu-toggle #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), type: "button", data: data_attr) do output = content_tag(:span, toggle_text, class: "dropdown-toggle-text #{'is-default' if toggle_text == default_label}") output << icon('chevron-down') output.html_safe diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index fc308b3960e..40864bed0ff 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -50,8 +50,8 @@ module GitlabRoutingHelper namespace_project_cycle_analytics_path(project.namespace, project, *args) end - def project_builds_path(project, *args) - namespace_project_builds_path(project.namespace, project, *args) + def project_jobs_path(project, *args) + namespace_project_jobs_path(project.namespace, project, *args) end def project_ref_path(project, ref_name, *args) @@ -110,8 +110,8 @@ module GitlabRoutingHelper namespace_project_pipeline_url(pipeline.project.namespace, pipeline.project, pipeline.id, *args) end - def pipeline_build_url(pipeline, build, *args) - namespace_project_build_url(pipeline.project.namespace, pipeline.project, build.id, *args) + def pipeline_job_url(pipeline, build, *args) + namespace_project_job_url(pipeline.project.namespace, pipeline.project, build.id, *args) end def commits_url(entity, *args) @@ -215,13 +215,13 @@ module GitlabRoutingHelper case action when 'download' - download_namespace_project_build_artifacts_path(*args) + download_namespace_project_job_artifacts_path(*args) when 'browse' - browse_namespace_project_build_artifacts_path(*args) + browse_namespace_project_job_artifacts_path(*args) when 'file' - file_namespace_project_build_artifacts_path(*args) + file_namespace_project_job_artifacts_path(*args) when 'raw' - raw_namespace_project_build_artifacts_path(*args) + raw_namespace_project_job_artifacts_path(*args) end end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 9290e4ec133..5e8f0849969 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -199,6 +199,43 @@ module IssuablesHelper issuable_filter_params.any? { |k| params.key?(k) } end + def issuable_initial_data(issuable) + data = { + endpoint: namespace_project_issue_path(@project.namespace, @project, issuable), + canUpdate: can?(current_user, :update_issue, issuable), + canDestroy: can?(current_user, :destroy_issue, issuable), + canMove: current_user ? issuable.can_move?(current_user) : false, + issuableRef: issuable.to_reference, + isConfidential: issuable.confidential, + markdownPreviewUrl: preview_markdown_path(@project), + markdownDocs: help_page_path('user/markdown'), + projectsAutocompleteUrl: autocomplete_projects_path(project_id: @project.id), + issuableTemplates: issuable_templates(issuable), + projectPath: ref_project.path, + projectNamespace: ref_project.namespace.full_path, + initialTitleHtml: markdown_field(issuable, :title), + initialTitleText: issuable.title, + initialDescriptionHtml: markdown_field(issuable, :description), + initialDescriptionText: issuable.description + } + + data.merge!(updated_at_by(issuable)) + + data.to_json + end + + def updated_at_by(issuable) + return {} unless issuable.is_edited? + + { + updatedAt: issuable.updated_at.to_time.iso8601, + updatedBy: { + name: issuable.last_edited_by.name, + path: user_path(issuable.last_edited_by) + } + } + end + private def sidebar_gutter_collapsed? diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index e5b1e6e8bc7..4e6e6805920 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -69,13 +69,12 @@ module LabelsHelper end def render_colored_label(label, label_suffix = '', tooltip: true) - label_color = label.color || Label::DEFAULT_COLOR - text_color = text_color_for_bg(label_color) + text_color = text_color_for_bg(label.color) # Intentionally not using content_tag here so that this method can be called # by LabelReferenceFilter span = %(<span class="label color-label #{"has-tooltip" if tooltip}" ) + - %(style="background-color: #{label_color}; color: #{text_color}" ) + + %(style="background-color: #{label.color}; color: #{text_color}" ) + %(title="#{escape_once(label.description)}" data-container="body">) + %(#{escape_once(label.name)}#{label_suffix}</span>) diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 375110b77e2..3d4802290b5 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -50,7 +50,7 @@ module NotesHelper def link_to_reply_discussion(discussion, line_type = nil) return unless current_user - data = { discussion_id: discussion.id, line_type: line_type } + data = { discussion_id: discussion.reply_id, line_type: line_type } button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button', data: data, title: 'Add a reply' diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 98bbcfaaba5..7b0584c42a2 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 a7d1fe4aa47..1a4f1431bdc 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 09b73eee8cf..c0763a8a9c4 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/mailers/base_mailer.rb b/app/mailers/base_mailer.rb index d2980db218a..654468bc7fe 100644 --- a/app/mailers/base_mailer.rb +++ b/app/mailers/base_mailer.rb @@ -1,4 +1,6 @@ class BaseMailer < ActionMailer::Base + around_action :render_with_default_locale + helper ApplicationHelper helper MarkupHelper @@ -14,6 +16,10 @@ class BaseMailer < ActionMailer::Base private + def render_with_default_locale(&block) + Gitlab::I18n.with_default_locale(&block) + end + def default_sender_address address = Mail::Address.new(Gitlab.config.gitlab.email_from) address.display_name = Gitlab.config.gitlab.email_display_name diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 043f57241a3..9e04976e8fd 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -13,13 +13,13 @@ class ApplicationSetting < ActiveRecord::Base [\r\n] # any number of newline characters }x - serialize :restricted_visibility_levels - serialize :import_sources - serialize :disabled_oauth_sign_in_sources, Array - serialize :domain_whitelist, Array - serialize :domain_blacklist, Array - serialize :repository_storages - serialize :sidekiq_throttling_queues, Array + serialize :restricted_visibility_levels # rubocop:disable Cop/ActiverecordSerialize + serialize :import_sources # rubocop:disable Cop/ActiverecordSerialize + serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiverecordSerialize + serialize :domain_whitelist, Array # rubocop:disable Cop/ActiverecordSerialize + serialize :domain_blacklist, Array # rubocop:disable Cop/ActiverecordSerialize + serialize :repository_storages # rubocop:disable Cop/ActiverecordSerialize + serialize :sidekiq_throttling_queues, Array # rubocop:disable Cop/ActiverecordSerialize cache_markdown_field :sign_in_text cache_markdown_field :help_page_text @@ -143,7 +143,7 @@ class ApplicationSetting < ActiveRecord::Base validates_each :restricted_visibility_levels do |record, attr, value| value&.each do |level| - unless Gitlab::VisibilityLevel.options.has_value?(level) + unless Gitlab::VisibilityLevel.options.value?(level) record.errors.add(attr, "'#{level}' is not a valid visibility level") end end @@ -151,7 +151,7 @@ class ApplicationSetting < ActiveRecord::Base validates_each :import_sources do |record, attr, value| value&.each do |source| - unless Gitlab::ImportSources.options.has_value?(source) + unless Gitlab::ImportSources.options.value?(source) record.errors.add(attr, "'#{source}' is not a import source") end end diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 967ffd46db0..46d412fbd72 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -1,5 +1,5 @@ class AuditEvent < ActiveRecord::Base - serialize :details, Hash + serialize :details, Hash # rubocop:disable Cop/ActiverecordSerialize belongs_to :user, foreign_key: :author_id diff --git a/app/models/blob.rb b/app/models/blob.rb index e75926241ba..6a42a12891c 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -102,10 +102,6 @@ class Blob < SimpleDelegator raw_size == 0 end - def too_large? - size && truncated? - end - def external_storage_error? if external_storage == :lfs !project&.lfs_enabled? @@ -160,7 +156,7 @@ class Blob < SimpleDelegator end def readable_text? - text? && !stored_externally? && !too_large? + text? && !stored_externally? && !truncated? end def simple_viewer @@ -187,9 +183,9 @@ class Blob < SimpleDelegator rendered_as_text? && rich_viewer end - def override_max_size! - simple_viewer&.override_max_size = true - rich_viewer&.override_max_size = true + def expand! + simple_viewer&.expanded = true + rich_viewer&.expanded = true end private diff --git a/app/models/blob_viewer/auxiliary.rb b/app/models/blob_viewer/auxiliary.rb index 07a207730cf..1bea225f17c 100644 --- a/app/models/blob_viewer/auxiliary.rb +++ b/app/models/blob_viewer/auxiliary.rb @@ -7,8 +7,8 @@ module BlobViewer included do self.loading_partial_name = 'loading_auxiliary' self.type = :auxiliary - self.overridable_max_size = 100.kilobytes - self.max_size = 100.kilobytes + self.collapse_limit = 100.kilobytes + self.size_limit = 100.kilobytes end def visible_to?(current_user) diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb index 26a3778c2a3..e6119d25fab 100644 --- a/app/models/blob_viewer/base.rb +++ b/app/models/blob_viewer/base.rb @@ -2,14 +2,14 @@ module BlobViewer class Base PARTIAL_PATH_PREFIX = 'projects/blob/viewers'.freeze - class_attribute :partial_name, :loading_partial_name, :type, :extensions, :file_types, :load_async, :binary, :switcher_icon, :switcher_title, :overridable_max_size, :max_size + class_attribute :partial_name, :loading_partial_name, :type, :extensions, :file_types, :load_async, :binary, :switcher_icon, :switcher_title, :collapse_limit, :size_limit self.loading_partial_name = 'loading' delegate :partial_path, :loading_partial_path, :rich?, :simple?, :text?, :binary?, to: :class attr_reader :blob - attr_accessor :override_max_size + attr_accessor :expanded delegate :project, to: :blob @@ -61,24 +61,16 @@ module BlobViewer self.class.load_async? && render_error.nil? end - def exceeds_overridable_max_size? - overridable_max_size && blob.raw_size > overridable_max_size - end - - def exceeds_max_size? - max_size && blob.raw_size > max_size - end + def collapsed? + return @collapsed if defined?(@collapsed) - def can_override_max_size? - exceeds_overridable_max_size? && !exceeds_max_size? + @collapsed = !expanded && collapse_limit && blob.raw_size > collapse_limit end def too_large? - if override_max_size - exceeds_max_size? - else - exceeds_overridable_max_size? - end + return @too_large if defined?(@too_large) + + @too_large = size_limit && blob.raw_size > size_limit end # This method is used on the server side to check whether we can attempt to @@ -95,6 +87,8 @@ module BlobViewer def render_error if too_large? :too_large + elsif collapsed? + :collapsed end end diff --git a/app/models/blob_viewer/client_side.rb b/app/models/blob_viewer/client_side.rb index cc68236f92b..079cfbe3616 100644 --- a/app/models/blob_viewer/client_side.rb +++ b/app/models/blob_viewer/client_side.rb @@ -4,8 +4,8 @@ module BlobViewer included do self.load_async = false - self.overridable_max_size = 10.megabytes - self.max_size = 50.megabytes + self.collapse_limit = 10.megabytes + self.size_limit = 50.megabytes end end end diff --git a/app/models/blob_viewer/server_side.rb b/app/models/blob_viewer/server_side.rb index 87884dcd6bf..05a3dd7d913 100644 --- a/app/models/blob_viewer/server_side.rb +++ b/app/models/blob_viewer/server_side.rb @@ -4,8 +4,8 @@ module BlobViewer included do self.load_async = true - self.overridable_max_size = 2.megabytes - self.max_size = 5.megabytes + self.collapse_limit = 2.megabytes + self.size_limit = 5.megabytes end def prepare! diff --git a/app/models/blob_viewer/text.rb b/app/models/blob_viewer/text.rb index eddca50b4d4..f68cbb7e212 100644 --- a/app/models/blob_viewer/text.rb +++ b/app/models/blob_viewer/text.rb @@ -5,7 +5,7 @@ module BlobViewer self.partial_name = 'text' self.binary = false - self.overridable_max_size = 1.megabyte - self.max_size = 10.megabytes + self.collapse_limit = 1.megabyte + self.size_limit = 10.megabytes end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 760ec8e5919..cec1ca89a6a 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -19,8 +19,8 @@ module Ci ) end - serialize :options - serialize :yaml_variables, Gitlab::Serializer::Ci::Variables + serialize :options # rubocop:disable Cop/ActiverecordSerialize + serialize :yaml_variables, Gitlab::Serializer::Ci::Variables # rubocop:disable Cop/ActiverecordSerialize delegate :name, to: :project, prefix: true @@ -47,10 +47,16 @@ module Ci before_destroy { unscoped_project } after_create :execute_hooks - after_save :update_project_statistics, if: :artifacts_size_changed? - after_destroy :update_project_statistics + after_commit :update_project_statistics_after_save, on: [:create, :update] + after_commit :update_project_statistics, on: :destroy class << self + # This is needed for url_for to work, + # as the controller is JobsController + def model_name + ActiveModel::Name.new(self, nil, 'job') + end + def first_pending pending.unstarted.order('created_at ASC').first end @@ -132,6 +138,17 @@ module Ci ExpandVariables.expand(environment, simple_variables) if environment end + def environment_url + return @environment_url if defined?(@environment_url) + + @environment_url = + if unexpanded_url = options&.dig(:environment, :url) + ExpandVariables.expand(unexpanded_url, simple_variables) + else + persisted_environment&.external_url + end + end + def has_environment? environment.present? end @@ -185,27 +202,30 @@ module Ci variables += project.deployment_variables if has_environment? variables += yaml_variables variables += user_variables - variables += project.secret_variables + variables += project.secret_variables_for(ref).map(&:to_runner_variable) variables += trigger_request.user_variables if trigger_request variables end # All variables, including those dependent on other variables def variables - variables = simple_variables - variables += persisted_environment.predefined_variables if persisted_environment.present? - variables + simple_variables.concat(persisted_environment_variables) end def merge_request - merge_requests = MergeRequest.includes(:merge_request_diff) - .where(source_branch: ref, - source_project: pipeline.project) - .reorder(iid: :asc) - - merge_requests.find do |merge_request| - merge_request.commits_sha.include?(pipeline.sha) - end + return @merge_request if defined?(@merge_request) + + @merge_request ||= + begin + merge_requests = MergeRequest.includes(:merge_request_diff) + .where(source_branch: ref, + source_project: pipeline.project) + .reorder(iid: :desc) + + merge_requests.find do |merge_request| + merge_request.commits_sha.include?(pipeline.sha) + end + end end def repo_url @@ -249,38 +269,6 @@ module Ci Time.now - updated_at > 15.minutes.to_i end - ## - # Deprecated - # - # This contains a hotfix for CI build data integrity, see #4246 - # - # This method is used by `ArtifactUploader` to create a store_dir. - # Warning: Uploader uses it after AND before file has been stored. - # - # This method returns old path to artifacts only if it already exists. - # - def artifacts_path - # We need the project even if it's soft deleted, because whenever - # we're really deleting the project, we'll also delete the builds, - # and in order to delete the builds, we need to know where to find - # the artifacts, which is depending on the data of the project. - # We need to retain the project in this case. - the_project = project || unscoped_project - - old = File.join(created_at.utc.strftime('%Y_%m'), - the_project.ci_id.to_s, - id.to_s) - - old_store = File.join(ArtifactUploader.artifacts_path, old) - return old if the_project.ci_id && File.directory?(old_store) - - File.join( - created_at.utc.strftime('%Y_%m'), - the_project.id.to_s, - id.to_s - ) - end - def valid_token?(token) self.token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.token) end @@ -361,7 +349,7 @@ module Ci end def has_expiring_artifacts? - artifacts_expire_at.present? + artifacts_expire_at.present? && artifacts_expire_at > Time.now end def keep_artifacts! @@ -488,6 +476,18 @@ module Ci variables.concat(legacy_variables) end + def persisted_environment_variables + return [] unless persisted_environment + + variables = persisted_environment.predefined_variables + + if url = environment_url + variables << { key: 'CI_ENVIRONMENT_URL', value: url, public: true } + end + + variables + end + def legacy_variables variables = [ { key: 'CI_BUILD_ID', value: id.to_s, public: true }, @@ -517,5 +517,11 @@ module Ci ProjectCacheWorker.perform_async(project_id, [], [:build_artifacts_size]) end + + def update_project_statistics_after_save + if previous_changes.include?('artifacts_size') + update_project_statistics + end + end end end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 81c30b0e077..425ca9278eb 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -30,6 +30,7 @@ module Ci delegate :id, to: :project, prefix: true + validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create validates :sha, presence: { unless: :importing? } validates :ref, presence: { unless: :importing? } validates :status, presence: { unless: :importing? } @@ -37,6 +38,16 @@ module Ci after_create :keep_around_commits, unless: :importing? + enum source: { + unknown: nil, + push: 1, + web: 2, + trigger: 3, + schedule: 4, + api: 5, + external: 6 + } + state_machine :status, initial: :created do event :enqueue do transition created: :pending @@ -269,10 +280,6 @@ module Ci commit.sha == sha end - def triggered? - trigger_requests.any? - end - def retried @retried ||= (statuses.order(id: :desc) - statuses.latest) end diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index cf6e53c4ca4..45d8cd34359 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 @@ -24,6 +24,10 @@ module Ci owner == current_user end + def own!(user) + update(owner: user) + end + def inactive? !active? end @@ -32,10 +36,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/ci/trigger_request.rb b/app/models/ci/trigger_request.rb index 2b807731d0d..564334ad1ad 100644 --- a/app/models/ci/trigger_request.rb +++ b/app/models/ci/trigger_request.rb @@ -6,7 +6,7 @@ module Ci belongs_to :pipeline, foreign_key: :commit_id has_many :builds - serialize :variables + serialize :variables # rubocop:disable Cop/ActiverecordSerialize def user_variables return [] unless variables diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 6c6586110c5..f235260208f 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -12,11 +12,16 @@ module Ci message: "can contain only letters, digits and '_'." } scope :order_key_asc, -> { reorder(key: :asc) } + scope :unprotected, -> { where(protected: false) } attr_encrypted :value, mode: :per_attribute_iv_and_salt, insecure_mode: true, key: Gitlab::Application.secrets.db_key_base, algorithm: 'aes-256-cbc' + + def to_runner_variable + { key: key, value: value, public: false } + end end end diff --git a/app/models/commit.rb b/app/models/commit.rb index dbc0a22829e..bfa3a624e70 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -14,7 +14,7 @@ class Commit participant :committer participant :notes_with_associations - attr_accessor :project + attr_accessor :project, :author DIFF_SAFE_LINES = Gitlab::Git::DiffCollection::DEFAULT_LIMITS[:max_lines] @@ -177,7 +177,7 @@ class Commit if RequestStore.active? key = "commit_author:#{author_email.downcase}" # nil is a valid value since no author may exist in the system - if RequestStore.store.has_key?(key) + if RequestStore.store.key?(key) @author = RequestStore.store[key] else @author = find_author_by_any_email @@ -326,11 +326,12 @@ class Commit end def raw_diffs(*args) - if Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) - Gitlab::GitalyClient::Commit.new(project.repository).diff_from_parent(self, *args) - else - raw.diffs(*args) - end + # Uncomment when https://gitlab.com/gitlab-org/gitaly/merge_requests/170 is merged + # if Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs) + # Gitlab::GitalyClient::Commit.new(project.repository).diff_from_parent(self, *args) + # else + raw.diffs(*args) + # end end def raw_deltas diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index fe63728ea23..8b4ed49269d 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -18,7 +18,7 @@ class CommitStatus < ActiveRecord::Base validates :name, presence: true alias_attribute :author, :user - + scope :failed_but_allowed, -> do where(allow_failure: true, status: [:failed, :canceled]) end @@ -83,14 +83,15 @@ class CommitStatus < ActiveRecord::Base next if transition.loopback? commit_status.run_after_commit do - pipeline.try do |pipeline| + if pipeline if complete? || manual? PipelineProcessWorker.perform_async(pipeline.id) else PipelineUpdateWorker.perform_async(pipeline.id) end - ExpireJobCacheWorker.perform_async(commit_status.id) end + + ExpireJobCacheWorker.perform_async(commit_status.id) end end @@ -126,6 +127,11 @@ class CommitStatus < ActiveRecord::Base false end + # To be overriden when inherrited from + def retryable? + false + end + def stuck? false end diff --git a/app/models/concerns/editable.rb b/app/models/concerns/editable.rb new file mode 100644 index 00000000000..c62c7e1e936 --- /dev/null +++ b/app/models/concerns/editable.rb @@ -0,0 +1,7 @@ +module Editable + extend ActiveSupport::Concern + + def is_edited? + last_edited_at.present? && last_edited_at != created_at + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 075ec575f9d..ea10d004c9c 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -15,6 +15,7 @@ module Issuable include Taskable include TimeTrackable include Importable + include Editable # This object is used to gather issuable meta data for displaying # upvotes, downvotes, notes and closing merge requests count for issues and merge requests diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb index 6359f7596b1..f734952fa6c 100644 --- a/app/models/concerns/note_on_diff.rb +++ b/app/models/concerns/note_on_diff.rb @@ -33,14 +33,4 @@ module NoteOnDiff def created_at_diff?(diff_refs) false end - - private - - def noteable_diff_refs - if noteable.respond_to?(:diff_sha_refs) - noteable.diff_sha_refs - else - noteable.diff_refs - end - end end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index dd1e6630642..c7bdc997eca 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -43,7 +43,12 @@ module Noteable end def resolvable_discussions - @resolvable_discussions ||= discussion_notes.resolvable.discussions(self) + @resolvable_discussions ||= + if defined?(@discussions) + @discussions.select(&:resolvable?) + else + discussion_notes.resolvable.discussions(self) + end end def discussions_resolvable? diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index c4463abdfe6..63d02b76f6b 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 50a1d7fc3e1..58194b0ea13 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/conversational_development_index/card.rb b/app/models/conversational_development_index/card.rb new file mode 100644 index 00000000000..e8f09dc9161 --- /dev/null +++ b/app/models/conversational_development_index/card.rb @@ -0,0 +1,26 @@ +module ConversationalDevelopmentIndex + class Card + attr_accessor :metric, :title, :description, :feature, :blog, :docs + + def initialize(metric:, title:, description:, feature:, blog:, docs: nil) + self.metric = metric + self.title = title + self.description = description + self.feature = feature + self.blog = blog + self.docs = docs + end + + def instance_score + metric.instance_score(feature) + end + + def leader_score + metric.leader_score(feature) + end + + def percentage_score + metric.percentage_score(feature) + end + end +end diff --git a/app/models/conversational_development_index/idea_to_production_step.rb b/app/models/conversational_development_index/idea_to_production_step.rb new file mode 100644 index 00000000000..6e1753c9f30 --- /dev/null +++ b/app/models/conversational_development_index/idea_to_production_step.rb @@ -0,0 +1,19 @@ +module ConversationalDevelopmentIndex + class IdeaToProductionStep + attr_accessor :metric, :title, :features + + def initialize(metric:, title:, features:) + self.metric = metric + self.title = title + self.features = features + end + + def percentage_score + sum = features.sum do |feature| + metric.percentage_score(feature) + end + + sum / features.size.to_f + end + end +end diff --git a/app/models/conversational_development_index/metric.rb b/app/models/conversational_development_index/metric.rb new file mode 100644 index 00000000000..f42f516f99a --- /dev/null +++ b/app/models/conversational_development_index/metric.rb @@ -0,0 +1,21 @@ +module ConversationalDevelopmentIndex + class Metric < ActiveRecord::Base + include Presentable + + self.table_name = 'conversational_development_index_metrics' + + def instance_score(feature) + self["instance_#{feature}"] + end + + def leader_score(feature) + self["leader_#{feature}"] + end + + def percentage_score(feature) + return 100 if leader_score(feature).zero? + + 100 * instance_score(feature) / leader_score(feature) + end + end +end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 216cec751e3..304179c0a97 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -12,6 +12,7 @@ class Deployment < ActiveRecord::Base delegate :name, to: :environment, prefix: true after_create :create_ref + after_create :invalidate_cache def commit project.commit(sha) @@ -33,6 +34,10 @@ class Deployment < ActiveRecord::Base project.repository.create_ref(ref, ref_path) end + def invalidate_cache + environment.expire_etag_cache + end + def manual_actions @manual_actions ||= deployable.try(:other_actions) end diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb index 800574d8be0..07c4846e2ac 100644 --- a/app/models/diff_discussion.rb +++ b/app/models/diff_discussion.rb @@ -10,6 +10,7 @@ class DiffDiscussion < Discussion delegate :position, :original_position, + :change_position, to: :first_note diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 1764004078e..20ef1378500 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -6,9 +6,9 @@ class DiffNote < Note NOTEABLE_TYPES = %w(MergeRequest Commit).freeze - serialize :original_position, Gitlab::Diff::Position - serialize :position, Gitlab::Diff::Position - serialize :change_position, Gitlab::Diff::Position + serialize :original_position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize + serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize + serialize :change_position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize validates :original_position, presence: true validates :position, presence: true @@ -63,7 +63,7 @@ class DiffNote < Note return false unless supported? return true if for_commit? - diff_refs ||= noteable_diff_refs + diff_refs ||= noteable.diff_refs self.position.diff_refs == diff_refs end @@ -95,13 +95,21 @@ class DiffNote < Note return if active? - Notes::DiffPositionUpdateService.new( - self.project, - nil, + tracer = Gitlab::Diff::PositionTracer.new( + project: self.project, old_diff_refs: self.position.diff_refs, - new_diff_refs: noteable_diff_refs, + new_diff_refs: self.noteable.diff_refs, paths: self.position.paths - ).execute(self) + ) + + result = tracer.trace(self.position) + return unless result + + if result[:outdated] + self.change_position = result[:position] + else + self.position = result[:position] + end end def verify_supported diff --git a/app/models/discussion.rb b/app/models/discussion.rb index 0b6b920ed66..d1cec7613af 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -21,7 +21,8 @@ class Discussion end def self.build_collection(notes, context_noteable = nil) - notes.group_by { |n| n.discussion_id(context_noteable) }.values.map { |notes| build(notes, context_noteable) } + grouped_notes = notes.group_by { |n| n.discussion_id(context_noteable) } + grouped_notes.values.map { |notes| build(notes, context_noteable) } end # Returns an alphanumeric discussion ID based on `build_discussion_id` @@ -84,6 +85,12 @@ class Discussion first_note.discussion_id(context_noteable) end + def reply_id + # To reply to this discussion, we need the actual discussion_id from the database, + # not the potentially overwritten one based on the noteable. + first_note.discussion_id + end + alias_method :to_param, :id def diff_discussion? diff --git a/app/models/environment.rb b/app/models/environment.rb index 61572d8d69a..6211a5c1e63 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -57,6 +57,10 @@ class Environment < ActiveRecord::Base state :available state :stopped + + after_transition do |environment| + environment.expire_etag_cache + end end def predefined_variables @@ -196,6 +200,18 @@ class Environment < ActiveRecord::Base [external_url, public_path].join('/') end + def expire_etag_cache + Gitlab::EtagCaching::Store.new.tap do |store| + store.touch(etag_cache_key) + end + end + + def etag_cache_key + Gitlab::Routing.url_helpers.namespace_project_environments_path( + project.namespace, + project) + end + private # Slugifying a name may remove the uniqueness guarantee afforded by it being diff --git a/app/models/event.rb b/app/models/event.rb index e6fad46077a..46e89388bc1 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -26,7 +26,7 @@ class Event < ActiveRecord::Base belongs_to :target, polymorphic: true # For Hash only - serialize :data + serialize :data # rubocop:disable Cop/ActiverecordSerialize # Callbacks after_create :reset_project_activity diff --git a/app/models/group.rb b/app/models/group.rb index 6aab477f431..be944da5a67 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/hooks/service_hook.rb b/app/models/hooks/service_hook.rb index eef24052a06..40e43c27f91 100644 --- a/app/models/hooks/service_hook.rb +++ b/app/models/hooks/service_hook.rb @@ -2,6 +2,6 @@ class ServiceHook < WebHook belongs_to :service def execute(data) - super(data, 'service_hook') + WebHookService.new(self, data, 'service_hook').execute end end diff --git a/app/models/hooks/system_hook.rb b/app/models/hooks/system_hook.rb index c645805c6da..1584235ab00 100644 --- a/app/models/hooks/system_hook.rb +++ b/app/models/hooks/system_hook.rb @@ -3,8 +3,4 @@ class SystemHook < WebHook default_value_for :push_events, false default_value_for :repository_update_events, true - - def async_execute(data, hook_name) - Sidekiq::Client.enqueue(SystemHookWorker, id, data, hook_name) - end end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index a165fdc312f..7503f3739c3 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -1,6 +1,5 @@ class WebHook < ActiveRecord::Base include Sortable - include HTTParty default_value_for :push_events, true default_value_for :issues_events, false @@ -13,52 +12,18 @@ class WebHook < ActiveRecord::Base default_value_for :repository_update_events, false default_value_for :enable_ssl_verification, true + has_many :web_hook_logs, dependent: :destroy + scope :push_hooks, -> { where(push_events: true) } scope :tag_push_hooks, -> { where(tag_push_events: true) } - # HTTParty timeout - default_timeout Gitlab.config.gitlab.webhook_timeout - validates :url, presence: true, url: true def execute(data, hook_name) - parsed_url = URI.parse(url) - if parsed_url.userinfo.blank? - response = WebHook.post(url, - body: data.to_json, - headers: build_headers(hook_name), - verify: enable_ssl_verification) - else - post_url = url.gsub("#{parsed_url.userinfo}@", '') - auth = { - username: CGI.unescape(parsed_url.user), - password: CGI.unescape(parsed_url.password) - } - response = WebHook.post(post_url, - body: data.to_json, - headers: build_headers(hook_name), - verify: enable_ssl_verification, - basic_auth: auth) - end - - [response.code, response.to_s] - rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout => e - logger.error("WebHook Error => #{e}") - [false, e.to_s] + WebHookService.new(self, data, hook_name).execute end def async_execute(data, hook_name) - Sidekiq::Client.enqueue(ProjectWebHookWorker, id, data, hook_name) - end - - private - - def build_headers(hook_name) - headers = { - 'Content-Type' => 'application/json', - 'X-Gitlab-Event' => hook_name.singularize.titleize - } - headers['X-Gitlab-Token'] = token if token.present? - headers + WebHookService.new(self, data, hook_name).async_execute end end diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb new file mode 100644 index 00000000000..d73cfcf630d --- /dev/null +++ b/app/models/hooks/web_hook_log.rb @@ -0,0 +1,13 @@ +class WebHookLog < ActiveRecord::Base + belongs_to :web_hook + + serialize :request_headers, Hash # rubocop:disable Cop/ActiverecordSerialize + serialize :request_data, Hash # rubocop:disable Cop/ActiverecordSerialize + serialize :response_headers, Hash # rubocop:disable Cop/ActiverecordSerialize + + validates :web_hook, presence: true + + def success? + response_status =~ /^2/ + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb index a88dbb3e065..693cc21bb40 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -251,9 +251,9 @@ class Issue < ActiveRecord::Base def as_json(options = {}) super(options).tap do |json| - json[:subscribed] = subscribed?(options[:user], project) if options.has_key?(:user) && options[:user] + json[:subscribed] = subscribed?(options[:user], project) if options.key?(:user) && options[:user] - if options.has_key?(:labels) + if options.key?(:labels) json[:labels] = labels.as_json( project: project, only: [:id, :title, :description, :color, :priority], diff --git a/app/models/key.rb b/app/models/key.rb index b7956052c3f..cb8f10f6d55 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -1,7 +1,6 @@ require 'digest/md5' class Key < ActiveRecord::Base - include AfterCommitQueue include Sortable LAST_USED_AT_REFRESH_TIME = 1.day.to_i @@ -25,10 +24,10 @@ class Key < ActiveRecord::Base delegate :name, :email, to: :user, prefix: true - after_create :add_to_shell - after_create :notify_user + after_commit :add_to_shell, on: :create + after_commit :notify_user, on: :create after_create :post_create_hook - after_destroy :remove_from_shell + after_commit :remove_from_shell, on: :destroy after_destroy :post_destroy_hook def key=(value) @@ -93,6 +92,6 @@ class Key < ActiveRecord::Base end def notify_user - run_after_commit { NotificationService.new.new_key(self) } + NotificationService.new.new_key(self) end end diff --git a/app/models/label.rb b/app/models/label.rb index ddddb6bdf8f..955d6b4079b 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -133,6 +133,10 @@ class Label < ActiveRecord::Base template end + def color + super || DEFAULT_COLOR + end + def text_color LabelsHelper.text_color_for_bg(self.color) end @@ -168,7 +172,7 @@ class Label < ActiveRecord::Base def as_json(options = {}) super(options).tap do |json| - json[:priority] = priority(options[:project]) if options.has_key?(:project) + json[:priority] = priority(options[:project]) if options.key?(:project) end end diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index d7c627432d2..7126de2d488 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -7,7 +7,7 @@ class LegacyDiffNote < Note include NoteOnDiff - serialize :st_diff + serialize :st_diff # rubocop:disable Cop/ActiverecordSerialize validates :line_code, presence: true, line_code: true @@ -61,7 +61,7 @@ class LegacyDiffNote < Note return true if for_commit? return true unless diff_line return false unless noteable - return false if diff_refs && diff_refs != noteable_diff_refs + return false if diff_refs && diff_refs != noteable.diff_refs noteable_diff = find_noteable_diff diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb index 007eed5600a..b0625c52b62 100644 --- a/app/models/lfs_objects_project.rb +++ b/app/models/lfs_objects_project.rb @@ -6,8 +6,7 @@ class LfsObjectsProject < ActiveRecord::Base validates :lfs_object_id, uniqueness: { scope: [:project_id], message: "already exists in project" } validates :project_id, presence: true - after_create :update_project_statistics - after_destroy :update_project_statistics + after_commit :update_project_statistics, on: [:create, :destroy] private diff --git a/app/models/list.rb b/app/models/list.rb index fbd19acd1f5..ba7353a1325 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -28,7 +28,7 @@ class List < ActiveRecord::Base def as_json(options = {}) super(options).tap do |json| - if options.has_key?(:label) + if options.key?(:label) json[:label] = label.as_json( project: board.project, only: [:id, :title, :description, :color] diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 2eec013fa9d..dd155252ad5 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -21,7 +21,7 @@ class MergeRequest < ActiveRecord::Base belongs_to :assignee, class_name: "User" - serialize :merge_params, Hash + serialize :merge_params, Hash # rubocop:disable Cop/ActiverecordSerialize after_create :ensure_merge_request_diff, unless: :importing? after_update :reload_diff_if_branch_changed @@ -220,10 +220,10 @@ class MergeRequest < ActiveRecord::Base def diffs(diff_options = {}) if compare - # When saving MR diffs, `no_collapse` is implicitly added (because we need + # When saving MR diffs, `expanded` is implicitly added (because we need # to save the entire contents to the DB), so add that here for # consistency. - compare.diffs(diff_options.merge(no_collapse: true)) + compare.diffs(diff_options.merge(expanded: true)) else merge_request_diff.diffs(diff_options) end @@ -245,19 +245,6 @@ class MergeRequest < ActiveRecord::Base end end - # MRs created before 8.4 don't store a MergeRequestDiff#base_commit_sha, - # but we need to get a commit for the "View file @ ..." link by deleted files, - # so we find the likely one if we can't get the actual one. - # This will not be the actual base commit if the target branch was merged into - # the source branch after the merge request was created, but it is good enough - # for the specific purpose of linking to a commit. - # It is not good enough for use in `Gitlab::Git::DiffRefs`, which needs the - # true base commit, so we can't simply have `#diff_base_commit` fall back on - # this method. - def likely_diff_base_commit - first_commit.try(:parent) || first_commit - end - def diff_start_commit if persisted? merge_request_diff.start_commit @@ -322,21 +309,14 @@ class MergeRequest < ActiveRecord::Base end def diff_refs - return unless diff_start_commit || diff_base_commit - - Gitlab::Diff::DiffRefs.new( - base_sha: diff_base_sha, - start_sha: diff_start_sha, - head_sha: diff_head_sha - ) - end - - # Return diff_refs instance trying to not touch the git repository - def diff_sha_refs - if merge_request_diff && merge_request_diff.diff_refs_by_sha? + if persisted? merge_request_diff.diff_refs else - diff_refs + Gitlab::Diff::DiffRefs.new( + base_sha: diff_base_sha, + start_sha: diff_start_sha, + head_sha: diff_head_sha + ) end end @@ -441,7 +421,7 @@ class MergeRequest < ActiveRecord::Base MergeRequests::MergeRequestDiffCacheService.new.execute(self) new_diff_refs = self.diff_refs - update_diff_notes_positions( + update_diff_discussion_positions( old_diff_refs: old_diff_refs, new_diff_refs: new_diff_refs, current_user: current_user @@ -870,22 +850,21 @@ class MergeRequest < ActiveRecord::Base end def has_complete_diff_refs? - diff_sha_refs && diff_sha_refs.complete? + diff_refs && diff_refs.complete? end - def update_diff_notes_positions(old_diff_refs:, new_diff_refs:, current_user: nil) + def update_diff_discussion_positions(old_diff_refs:, new_diff_refs:, current_user: nil) return unless has_complete_diff_refs? return if new_diff_refs == old_diff_refs - active_diff_notes = self.notes.new_diff_notes.select do |note| - note.active?(old_diff_refs) + active_diff_discussions = self.notes.new_diff_notes.discussions.select do |discussion| + discussion.active?(old_diff_refs) end + return if active_diff_discussions.empty? - return if active_diff_notes.empty? - - paths = active_diff_notes.flat_map { |n| n.diff_file.paths }.uniq + paths = active_diff_discussions.flat_map { |n| n.diff_file.paths }.uniq - service = Notes::DiffPositionUpdateService.new( + service = Discussions::UpdateDiffPositionService.new( self.project, current_user, old_diff_refs: old_diff_refs, @@ -893,11 +872,8 @@ class MergeRequest < ActiveRecord::Base paths: paths ) - transaction do - active_diff_notes.each do |note| - service.execute(note) - Gitlab::Timeless.timeless(note, &:save) - end + active_diff_discussions.each do |discussion| + service.execute(discussion) end end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 6e3917a10a3..99dd2130188 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -1,7 +1,7 @@ class MergeRequestDiff < ActiveRecord::Base include Sortable include Importable - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper # Prevent store of diff if commits amount more then 500 COMMITS_SAFE_SIZE = 100 @@ -11,8 +11,8 @@ class MergeRequestDiff < ActiveRecord::Base belongs_to :merge_request - serialize :st_commits - serialize :st_diffs + serialize :st_commits # rubocop:disable Cop/ActiverecordSerialize + serialize :st_diffs # rubocop:disable Cop/ActiverecordSerialize state_machine :state, initial: :empty do state :collected @@ -150,6 +150,29 @@ class MergeRequestDiff < ActiveRecord::Base ) end + # MRs created before 8.4 don't store their true diff refs (start and base), + # but we need to get a commit SHA for the "View file @ ..." link by a file, + # so we use an approximation of the diff refs if we can't get the actual one. + # + # These will not be the actual diff refs if the target branch was merged into + # the source branch after the merge request was created, but it is good enough + # for the specific purpose of linking to a commit. + # + # It is not good enough for highlighting diffs, so we can't simply pass + # these as `diff_refs.` + def fallback_diff_refs + real_refs = diff_refs + return real_refs if real_refs + + likely_base_commit_sha = (first_commit&.parent || first_commit)&.sha + + Gitlab::Diff::DiffRefs.new( + base_sha: likely_base_commit_sha, + start_sha: safe_start_commit_sha, + head_sha: head_commit_sha + ) + end + def diff_refs_by_sha? base_commit_sha? && head_commit_sha? && start_commit_sha? end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index c06bfe0ccdd..b04bed4c014 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 4d59267f71d..b48d73dcae7 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -6,6 +6,7 @@ class Namespace < ActiveRecord::Base include Gitlab::ShellAdapter include Gitlab::CurrentSettings include Routable + include AfterCommitQueue # Prevent users from creating unreasonably deep level of nesting. # The number 20 was taken based on maximum nesting level of @@ -176,26 +177,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 @@ -248,7 +243,9 @@ class Namespace < ActiveRecord::Base # Remove namespace directroy async with delay so # GitLab has time to remove all projects first - GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path) + run_after_commit do + GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage_path, new_path) + end end end diff --git a/app/models/note.rb b/app/models/note.rb index 60257aac93b..563af47f314 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -13,6 +13,7 @@ class Note < ActiveRecord::Base include AfterCommitQueue include ResolvableNote include IgnorableColumn + include Editable ignore_column :original_discussion_id @@ -110,7 +111,7 @@ class Note < ActiveRecord::Base end def discussions(context_noteable = nil) - Discussion.build_collection(fresh, context_noteable) + Discussion.build_collection(all.includes(:noteable).fresh, context_noteable) end def find_discussion(discussion_id) diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index e8b000ddad6..ae9f71e7747 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -3,7 +3,7 @@ class PersonalAccessToken < ActiveRecord::Base include TokenAuthenticatable add_authentication_token_field :token - serialize :scopes, Array + serialize :scopes, Array # rubocop:disable Cop/ActiverecordSerialize belongs_to :user diff --git a/app/models/project.rb b/app/models/project.rb index cfca0dcd2f2..d21ff274b6e 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -205,8 +205,8 @@ class Project < ActiveRecord::Base presence: true, dynamic_path: true, length: { maximum: 255 }, - format: { with: Gitlab::Regex.project_path_format_regex, - message: Gitlab::Regex.project_path_regex_message }, + format: { with: Gitlab::PathRegex.project_path_format_regex, + message: Gitlab::PathRegex.project_path_format_message }, uniqueness: { scope: :namespace_id } validates :namespace, presence: true @@ -242,6 +242,7 @@ class Project < ActiveRecord::Base scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) } scope :personal, ->(user) { where(namespace_id: user.namespace_id) } scope :joined, ->(user) { where('namespace_id != ?', user.namespace_id) } + scope :starred_by, ->(user) { joins(:users_star_projects).where('users_star_projects.user_id': user.id) } scope :visible_to_user, ->(user) { where(id: user.authorized_projects.select(:id).reorder(nil)) } scope :non_archived, -> { where(archived: false) } scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct } @@ -271,6 +272,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 } @@ -349,10 +351,6 @@ class Project < ActiveRecord::Base where("projects.id IN (#{union.to_sql})") end - def search_by_visibility(level) - where(visibility_level: Gitlab::VisibilityLevel.string_options[level]) - end - def search_by_title(query) pattern = "%#{query}%" table = Project.arel_table @@ -380,11 +378,9 @@ class Project < ActiveRecord::Base end def reference_pattern - name_pattern = Gitlab::Regex::FULL_NAMESPACE_REGEX_STR - %r{ - ((?<namespace>#{name_pattern})\/)? - (?<project>#{name_pattern}) + ((?<namespace>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})\/)? + (?<project>#{Gitlab::PathRegex::PROJECT_PATH_FORMAT_REGEX}) }x end @@ -475,7 +471,9 @@ class Project < ActiveRecord::Base end def reset_cache_and_import_attrs - ProjectCacheWorker.perform_async(self.id) + run_after_commit do + ProjectCacheWorker.perform_async(self.id) + end self.import_data&.destroy end @@ -875,10 +873,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) @@ -1067,11 +1063,6 @@ class Project < ActiveRecord::Base pipelines.order(id: :desc).find_by(sha: sha, ref: ref) end - def ensure_pipeline(ref, sha, current_user = nil) - pipeline_for(ref, sha) || - pipelines.create(sha: sha, ref: ref, user: current_user) - end - def enable_ci project_feature.update_attribute(:builds_access_level, ProjectFeature::ENABLED) end @@ -1237,6 +1228,7 @@ class Project < ActiveRecord::Base { key: 'CI_PROJECT_ID', value: id.to_s, public: true }, { key: 'CI_PROJECT_NAME', value: path, public: true }, { key: 'CI_PROJECT_PATH', value: path_with_namespace, public: true }, + { key: 'CI_PROJECT_PATH_SLUG', value: path_with_namespace.parameterize, public: true }, { key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path, public: true }, { key: 'CI_PROJECT_URL', value: web_url, public: true } ] @@ -1256,12 +1248,19 @@ class Project < ActiveRecord::Base variables end - def secret_variables - variables.map do |variable| - { key: variable.key, value: variable.value, public: false } + def secret_variables_for(ref) + if protected_for?(ref) + variables + else + variables.unprotected end end + def protected_for?(ref) + ProtectedBranch.protected?(self, ref) || + ProtectedTag.protected?(self, ref) + end + def deployment_variables return [] unless deployment_service diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index 4c7f4f5a429..def09675253 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_import_data.rb b/app/models/project_import_data.rb index 331123a5a5b..e3cafd4d1c6 100644 --- a/app/models/project_import_data.rb +++ b/app/models/project_import_data.rb @@ -10,7 +10,7 @@ class ProjectImportData < ActiveRecord::Base insecure_mode: true, algorithm: 'aes-256-cbc' - serialize :data, JSON + serialize :data, JSON # rubocop:disable Cop/ActiverecordSerialize validates :project, presence: true diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb index 3728f5642e4..9ce2d1153a7 100644 --- a/app/models/project_services/asana_service.rb +++ b/app/models/project_services/asana_service.rb @@ -34,7 +34,8 @@ http://app.asana.com/-/account_api' { type: 'text', name: 'api_key', - placeholder: 'User Personal Access Token. User must have access to task, all comments will be attributed to this user.' + placeholder: 'User Personal Access Token. User must have access to task, all comments will be attributed to this user.', + required: true }, { type: 'text', diff --git a/app/models/project_services/assembla_service.rb b/app/models/project_services/assembla_service.rb index aeeff8917bf..ae6af732ed4 100644 --- a/app/models/project_services/assembla_service.rb +++ b/app/models/project_services/assembla_service.rb @@ -18,7 +18,7 @@ class AssemblaService < Service def fields [ - { type: 'text', name: 'token', placeholder: '' }, + { type: 'text', name: 'token', placeholder: '', required: true }, { type: 'text', name: 'subdomain', placeholder: '' } ] end diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb index 3f5b3eb159b..42939ea0ec8 100644 --- a/app/models/project_services/bamboo_service.rb +++ b/app/models/project_services/bamboo_service.rb @@ -47,9 +47,9 @@ class BambooService < CiService def fields [ { type: 'text', name: 'bamboo_url', - placeholder: 'Bamboo root URL like https://bamboo.example.com' }, + placeholder: 'Bamboo root URL like https://bamboo.example.com', required: true }, { type: 'text', name: 'build_key', - placeholder: 'Bamboo build plan key like KEY' }, + placeholder: 'Bamboo build plan key like KEY', required: true }, { type: 'text', name: 'username', placeholder: 'A user with API access, if applicable' }, { type: 'password', name: 'password' } diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb index 5fb95050b83..fc30f6e3365 100644 --- a/app/models/project_services/buildkite_service.rb +++ b/app/models/project_services/buildkite_service.rb @@ -58,11 +58,11 @@ class BuildkiteService < CiService [ { type: 'text', name: 'token', - placeholder: 'Buildkite project GitLab token' }, + placeholder: 'Buildkite project GitLab token', required: true }, { type: 'text', name: 'project_url', - placeholder: "#{ENDPOINT}/example/project" }, + placeholder: "#{ENDPOINT}/example/project", required: true }, { type: 'checkbox', name: 'enable_ssl_verification', diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb index 0de59af5652..c3f5b310619 100644 --- a/app/models/project_services/campfire_service.rb +++ b/app/models/project_services/campfire_service.rb @@ -18,7 +18,7 @@ class CampfireService < Service def fields [ - { type: 'text', name: 'token', placeholder: '' }, + { type: 'text', name: 'token', placeholder: '', required: true }, { type: 'text', name: 'subdomain', placeholder: '' }, { type: 'text', name: 'room', placeholder: '' } ] @@ -76,7 +76,7 @@ class CampfireService < Service # Returns a list of rooms, or []. # https://github.com/basecamp/campfire-api/blob/master/sections/rooms.md#get-rooms def rooms(auth) - res = self.class.get("/rooms.json", auth) + res = self.class.get("/rooms.json", auth) res.code == 200 ? res["rooms"] : [] end diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index 779ef54cfcb..6d1a321f651 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -21,10 +21,6 @@ class ChatNotificationService < Service end end - def can_test? - valid? - end - def self.supported_events %w[push issue confidential_issue merge_request note tag_push pipeline wiki_page] @@ -36,7 +32,7 @@ class ChatNotificationService < Service def default_fields [ - { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, + { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true }, { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, { type: 'checkbox', name: 'notify_only_default_branch' } diff --git a/app/models/project_services/custom_issue_tracker_service.rb b/app/models/project_services/custom_issue_tracker_service.rb index dea915a4d05..b9e3e982b64 100644 --- a/app/models/project_services/custom_issue_tracker_service.rb +++ b/app/models/project_services/custom_issue_tracker_service.rb @@ -31,9 +31,9 @@ class CustomIssueTrackerService < IssueTrackerService [ { type: 'text', name: 'title', placeholder: title }, { type: 'text', name: 'description', placeholder: description }, - { type: 'text', name: 'project_url', placeholder: 'Project url' }, - { type: 'text', name: 'issues_url', placeholder: 'Issue url' }, - { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url' } + { type: 'text', name: 'project_url', placeholder: 'Project url', required: true }, + { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true }, + { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true } ] end end diff --git a/app/models/project_services/deployment_service.rb b/app/models/project_services/deployment_service.rb index 91a55514a9a..5b8320158fc 100644 --- a/app/models/project_services/deployment_service.rb +++ b/app/models/project_services/deployment_service.rb @@ -30,4 +30,8 @@ class DeploymentService < Service def terminals(environment) raise NotImplementedError end + + def can_test? + false + end end diff --git a/app/models/project_services/drone_ci_service.rb b/app/models/project_services/drone_ci_service.rb index 2717c240f05..f6cade9c290 100644 --- a/app/models/project_services/drone_ci_service.rb +++ b/app/models/project_services/drone_ci_service.rb @@ -93,8 +93,8 @@ class DroneCiService < CiService def fields [ - { type: 'text', name: 'token', placeholder: 'Drone CI project specific token' }, - { type: 'text', name: 'drone_url', placeholder: 'http://drone.example.com' }, + { type: 'text', name: 'token', placeholder: 'Drone CI project specific token', required: true }, + { type: 'text', name: 'drone_url', placeholder: 'http://drone.example.com', required: true }, { type: 'checkbox', name: 'enable_ssl_verification', title: "Enable SSL verification" } ] end diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb index b4d7c977ce4..720ad61162e 100644 --- a/app/models/project_services/external_wiki_service.rb +++ b/app/models/project_services/external_wiki_service.rb @@ -19,7 +19,7 @@ class ExternalWikiService < Service def fields [ - { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki' } + { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki', required: true } ] end diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb index 2a05d757eb4..2db95b9aaa3 100644 --- a/app/models/project_services/flowdock_service.rb +++ b/app/models/project_services/flowdock_service.rb @@ -18,7 +18,7 @@ class FlowdockService < Service def fields [ - { type: 'text', name: 'token', placeholder: 'Flowdock Git source token' } + { type: 'text', name: 'token', placeholder: 'Flowdock Git source token', required: true } ] end diff --git a/app/models/project_services/gemnasium_service.rb b/app/models/project_services/gemnasium_service.rb index f271e1f1739..017a9b2df6e 100644 --- a/app/models/project_services/gemnasium_service.rb +++ b/app/models/project_services/gemnasium_service.rb @@ -18,8 +18,8 @@ class GemnasiumService < Service def fields [ - { type: 'text', name: 'api_key', placeholder: 'Your personal API KEY on gemnasium.com ' }, - { type: 'text', name: 'token', placeholder: 'The project\'s slug on gemnasium.com' } + { type: 'text', name: 'api_key', placeholder: 'Your personal API KEY on gemnasium.com ', required: true }, + { type: 'text', name: 'token', placeholder: 'The project\'s slug on gemnasium.com', required: true } ] end diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index c19fed339ba..e3906943ecd 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -33,7 +33,7 @@ class HipchatService < Service def fields [ - { type: 'text', name: 'token', placeholder: 'Room token' }, + { type: 'text', name: 'token', placeholder: 'Room token', required: true }, { type: 'text', name: 'room', placeholder: 'Room name or ID' }, { type: 'checkbox', name: 'notify' }, { type: 'select', name: 'color', choices: %w(yellow red green purple gray random) }, diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index a51d43adcb9..19357f90810 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -49,7 +49,7 @@ class IrkerService < Service help: 'A default IRC URI to prepend before each recipient (optional)', placeholder: 'irc://irc.network.net:6697/' }, { type: 'textarea', name: 'recipients', - placeholder: 'Recipients/channels separated by whitespaces', + placeholder: 'Recipients/channels separated by whitespaces', required: true, help: 'Recipients have to be specified with a full URI: '\ 'irc[s]://irc.network.net[:port]/#channel. Special cases: if '\ 'you want the channel to be a nickname instead, append ",isnick" to ' \ diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index eddf308eae3..ff138b9066d 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -32,9 +32,9 @@ class IssueTrackerService < Service def fields [ { type: 'text', name: 'description', placeholder: description }, - { type: 'text', name: 'project_url', placeholder: 'Project url' }, - { type: 'text', name: 'issues_url', placeholder: 'Issue url' }, - { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url' } + { type: 'text', name: 'project_url', placeholder: 'Project url', required: true }, + { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true }, + { type: 'text', name: 'new_issue_url', placeholder: 'New Issue url', required: true } ] end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index a91a986e195..2450fb43212 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,10 +86,11 @@ class JiraService < IssueTrackerService def fields [ - { type: 'text', name: 'url', title: 'URL', placeholder: 'https://jira.example.com' }, - { type: 'text', name: 'project_key', placeholder: 'Project Key' }, - { type: 'text', name: 'username', placeholder: '' }, - { type: 'password', name: 'password', placeholder: '' }, + { type: 'text', name: 'url', title: 'Web URL', placeholder: 'https://jira.example.com', required: true }, + { type: 'text', name: 'api_url', title: 'JIRA API URL', placeholder: 'If different from Web URL' }, + { type: 'text', name: 'project_key', placeholder: 'Project Key', required: true }, + { type: 'text', name: 'username', placeholder: '', required: true }, + { type: 'password', name: 'password', placeholder: '', required: true }, { type: 'text', name: 'jira_issue_transition_id', placeholder: '' } ] end @@ -175,10 +175,6 @@ class JiraService < IssueTrackerService { success: result.present?, result: result } end - def can_test? - username.present? && password.present? - end - # JIRA does not need test data. # We are requesting the project that belongs to the project key. def test_data(user = nil, project = nil) @@ -186,7 +182,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 +232,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 +300,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_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index b2494a0be6e..8977a7cdafe 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -77,6 +77,14 @@ class KubernetesService < DeploymentService ] end + def actual_namespace + if namespace.present? + namespace + else + default_namespace + end + end + # Check we can connect to the Kubernetes API def test(*args) kubeclient = build_kubeclient! @@ -91,7 +99,7 @@ class KubernetesService < DeploymentService variables = [ { key: 'KUBE_URL', value: api_url, public: true }, { key: 'KUBE_TOKEN', value: token, public: false }, - { key: 'KUBE_NAMESPACE', value: namespace_variable, public: true } + { key: 'KUBE_NAMESPACE', value: actual_namespace, public: true } ] if ca_pem.present? @@ -110,7 +118,7 @@ class KubernetesService < DeploymentService with_reactive_cache do |data| pods = data.fetch(:pods, nil) filter_pods(pods, app: environment.slug). - flat_map { |pod| terminals_for_pod(api_url, namespace, pod) }. + flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }. each { |terminal| add_terminal_auth(terminal, terminal_auth) } end end @@ -124,7 +132,7 @@ class KubernetesService < DeploymentService # Store as hashes, rather than as third-party types pods = begin - kubeclient.get_pods(namespace: namespace).as_json + kubeclient.get_pods(namespace: actual_namespace).as_json rescue KubeException => err raise err unless err.error_code == 404 [] @@ -142,20 +150,12 @@ class KubernetesService < DeploymentService default_namespace || TEMPLATE_PLACEHOLDER end - def namespace_variable - if namespace.present? - namespace - else - default_namespace - end - end - def default_namespace "#{project.path}-#{project.id}" if project.present? end def build_kubeclient!(api_path: 'api', api_version: 'v1') - raise "Incomplete settings" unless api_url && namespace && token + raise "Incomplete settings" unless api_url && actual_namespace && token ::Kubeclient::Client.new( join_api_url(api_path), diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb index 546b6e0a498..72ddf9a4be3 100644 --- a/app/models/project_services/mock_ci_service.rb +++ b/app/models/project_services/mock_ci_service.rb @@ -21,7 +21,8 @@ class MockCiService < CiService [ { type: 'text', name: 'mock_service_url', - placeholder: 'http://localhost:4004' } + placeholder: 'http://localhost:4004', + required: true } ] end @@ -79,4 +80,8 @@ class MockCiService < CiService :error end end + + def can_test? + false + end end diff --git a/app/models/project_services/mock_monitoring_service.rb b/app/models/project_services/mock_monitoring_service.rb index dd04e04e198..ed0318c6b27 100644 --- a/app/models/project_services/mock_monitoring_service.rb +++ b/app/models/project_services/mock_monitoring_service.rb @@ -14,4 +14,8 @@ class MockMonitoringService < MonitoringService def metrics(environment) JSON.parse(File.read(Rails.root + 'spec/fixtures/metrics.json')) end + + def can_test? + false + end end diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index f824171ad09..9d37184be2c 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -53,7 +53,8 @@ class PipelinesEmailService < Service [ { type: 'textarea', name: 'recipients', - placeholder: 'Emails separated by comma' }, + placeholder: 'Emails separated by comma', + required: true }, { type: 'checkbox', name: 'notify_only_broken_pipelines' } ] diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb index d86f4f6f448..f9dfa2e91c3 100644 --- a/app/models/project_services/pivotaltracker_service.rb +++ b/app/models/project_services/pivotaltracker_service.rb @@ -23,7 +23,8 @@ class PivotaltrackerService < Service { type: 'text', name: 'token', - placeholder: 'Pivotal Tracker API token.' + placeholder: 'Pivotal Tracker API token.', + required: true }, { type: 'text', diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index ec72cb6856d..110b8bc209b 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -49,7 +49,8 @@ class PrometheusService < MonitoringService type: 'text', name: 'api_url', title: 'API URL', - placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/' + placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/', + required: true } ] end diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb index fc29a5277bb..aa7bd4c3c84 100644 --- a/app/models/project_services/pushover_service.rb +++ b/app/models/project_services/pushover_service.rb @@ -19,10 +19,10 @@ class PushoverService < Service def fields [ - { type: 'text', name: 'api_key', placeholder: 'Your application key' }, - { type: 'text', name: 'user_key', placeholder: 'Your user key' }, + { type: 'text', name: 'api_key', placeholder: 'Your application key', required: true }, + { type: 'text', name: 'user_key', placeholder: 'Your user key', required: true }, { type: 'text', name: 'device', placeholder: 'Leave blank for all active devices' }, - { type: 'select', name: 'priority', choices: + { type: 'select', name: 'priority', required: true, choices: [ ['Lowest Priority', -2], ['Low Priority', -1], diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb index b16beb406b9..cbe137452bd 100644 --- a/app/models/project_services/teamcity_service.rb +++ b/app/models/project_services/teamcity_service.rb @@ -50,9 +50,9 @@ class TeamcityService < CiService def fields [ { type: 'text', name: 'teamcity_url', - placeholder: 'TeamCity root URL like https://teamcity.example.com' }, + placeholder: 'TeamCity root URL like https://teamcity.example.com', required: true }, { type: 'text', name: 'build_type', - placeholder: 'Build configuration ID' }, + placeholder: 'Build configuration ID', required: true }, { type: 'text', name: 'username', placeholder: 'A user with permissions to trigger a manual build' }, { type: 'password', name: 'password' } diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 543b9b293e0..e1cc56551ba 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -167,7 +167,7 @@ class ProjectTeam access = RequestStore.store[key] end - # Lookup only the IDs we need + # Look up only the IDs we need user_ids = user_ids - access.keys return access if user_ids.empty? @@ -178,6 +178,13 @@ class ProjectTeam maximum(:access_level) access.merge!(users_access) + + missing_user_ids = user_ids - users_access.keys + + missing_user_ids.each do |user_id| + access[user_id] = Gitlab::Access::NO_ACCESS + end + access end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 189c106b70b..f38fbda7839 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/sent_notification.rb b/app/models/sent_notification.rb index 0ae5864615a..eed3ca7e179 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -1,5 +1,5 @@ class SentNotification < ActiveRecord::Base - serialize :position, Gitlab::Diff::Position + serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiverecordSerialize belongs_to :project belongs_to :noteable, polymorphic: true diff --git a/app/models/service.rb b/app/models/service.rb index 8916f88076e..6a0b0a5c522 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -2,7 +2,7 @@ # and implement a set of methods class Service < ActiveRecord::Base include Sortable - serialize :properties, JSON + serialize :properties, JSON # rubocop:disable Cop/ActiverecordSerialize default_value_for :active, false default_value_for :push_events, true diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 882e2fa0594..6c3358685fe 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -8,6 +8,7 @@ class Snippet < ActiveRecord::Base include Awardable include Mentionable include Spammable + include Editable cache_markdown_field :title, pipeline: :single_line cache_markdown_field :content diff --git a/app/models/user.rb b/app/models/user.rb index cf3914568a6..e6eb9d09656 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 @@ -37,7 +40,7 @@ class User < ActiveRecord::Base otp_secret_encryption_key: Gitlab::Application.secrets.otp_key_base devise :two_factor_backupable, otp_number_of_backup_codes: 10 - serialize :otp_backup_codes, JSON + serialize :otp_backup_codes, JSON # rubocop:disable Cop/ActiverecordSerialize devise :lockable, :recoverable, :rememberable, :trackable, :validatable, :omniauthable, :confirmable, :registerable @@ -98,6 +101,7 @@ class User < ActiveRecord::Base has_many :snippets, dependent: :destroy, foreign_key: :author_id has_many :notes, dependent: :destroy, foreign_key: :author_id + has_many :issues, dependent: :destroy, foreign_key: :author_id has_many :merge_requests, dependent: :destroy, foreign_key: :author_id has_many :events, dependent: :destroy, foreign_key: :author_id has_many :subscriptions, dependent: :destroy @@ -117,11 +121,6 @@ class User < ActiveRecord::Base has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" - # Issues that a user owns are expected to be moved to the "ghost" user before - # the user is destroyed. If the user owns any issues during deletion, this - # should be treated as an exceptional condition. - has_many :issues, dependent: :restrict_with_exception, foreign_key: :author_id - # # Validations # @@ -218,7 +217,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')) } @@ -367,8 +365,9 @@ class User < ActiveRecord::Base # Pattern used to extract `@user` user references from text def reference_pattern %r{ + (?<!\w) #{Regexp.escape(reference_prefix)} - (?<user>#{Gitlab::Regex::FULL_NAMESPACE_REGEX_STR}) + (?<user>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX}) }x end @@ -510,23 +509,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 +527,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 @@ -565,12 +554,6 @@ class User < ActiveRecord::Base authorized_projects(Gitlab::Access::REPORTER).where(id: projects) end - def viewable_starred_projects - starred_projects.where("projects.visibility_level IN (?) OR projects.id IN (?)", - [Project::PUBLIC, Project::INTERNAL], - authorized_projects.select(:project_id)) - end - def owned_projects @owned_projects ||= Project.where('namespace_id IN (?) OR namespace_id = ?', @@ -794,7 +777,7 @@ class User < ActiveRecord::Base def avatar_url(size: nil, scale: 2, **args) # We use avatar_path instead of overriding avatar_url because of carrierwave. # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11001/diffs#note_28659864 - avatar_path(args) || GravatarService.new.execute(email, size, scale) + avatar_path(args) || GravatarService.new.execute(email, size, scale, username: username) end def all_emails @@ -919,13 +902,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/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index d4af4490608..2d7405dc240 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -23,7 +23,7 @@ module Ci !::Gitlab::UserAccess .new(user, project: build.project) - .can_push_to_branch?(build.ref) + .can_merge_to_branch?(build.ref) end end end diff --git a/app/presenters/conversational_development_index/metric_presenter.rb b/app/presenters/conversational_development_index/metric_presenter.rb new file mode 100644 index 00000000000..bb65ba2646b --- /dev/null +++ b/app/presenters/conversational_development_index/metric_presenter.rb @@ -0,0 +1,144 @@ +module ConversationalDevelopmentIndex + class MetricPresenter < Gitlab::View::Presenter::Simple + def cards + [ + Card.new( + metric: subject, + title: 'Issues', + description: 'created per active user', + feature: 'issues', + blog: 'https://www2.deloitte.com/content/dam/Deloitte/se/Documents/technology-media-telecommunications/deloitte-digital-collaboration.pdf' + ), + Card.new( + metric: subject, + title: 'Comments', + description: 'created per active user', + feature: 'notes', + blog: 'http://conversationaldevelopment.com/why/' + ), + Card.new( + metric: subject, + title: 'Milestones', + description: 'created per active user', + feature: 'milestones', + blog: 'http://conversationaldevelopment.com/shorten-cycle/', + docs: help_page_path('user/project/milestones/index') + ), + Card.new( + metric: subject, + title: 'Boards', + description: 'created per active user', + feature: 'boards', + blog: 'http://jpattonassociates.com/user-story-mapping/', + docs: help_page_path('user/project/issue_board') + ), + Card.new( + metric: subject, + title: 'Merge Requests', + description: 'per active user', + feature: 'merge_requests', + blog: 'https://8thlight.com/blog/uncle-bob/2013/02/01/The-Humble-Craftsman.html', + docs: help_page_path('user/project/merge_requests/index') + ), + Card.new( + metric: subject, + title: 'Pipelines', + description: 'created per active user', + feature: 'ci_pipelines', + blog: 'https://martinfowler.com/bliki/ContinuousDelivery.html', + docs: help_page_path('ci/README') + ), + Card.new( + metric: subject, + title: 'Environments', + description: 'created per active user', + feature: 'environments', + blog: 'https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/', + docs: help_page_path('ci/environments') + ), + Card.new( + metric: subject, + title: 'Deployments', + description: 'created per active user', + feature: 'deployments', + blog: 'https://puppet.com/blog/continuous-delivery-vs-continuous-deployment-what-s-diff' + ), + Card.new( + metric: subject, + title: 'Monitoring', + description: 'fraction of all projects', + feature: 'projects_prometheus_active', + blog: 'https://prometheus.io/docs/introduction/overview/', + docs: help_page_path('user/project/integrations/prometheus') + ), + Card.new( + metric: subject, + title: 'Service Desk', + description: 'issues created per active user', + feature: 'service_desk_issues', + blog: 'http://blogs.forrester.com/kate_leggett/17-01-30-top_trends_for_customer_service_in_2017_operations_become_smarter_and_more_strategic', + docs: 'https://docs.gitlab.com/ee/user/project/service_desk.html' + ) + ] + end + + def idea_to_production_steps + [ + IdeaToProductionStep.new( + metric: subject, + title: 'Idea', + features: %w(issues) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Issue', + features: %w(issues notes) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Plan', + features: %w(milestones boards) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Code', + features: %w(merge_requests) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Commit', + features: %w(merge_requests) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Test', + features: %w(ci_pipelines) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Review', + features: %w(ci_pipelines environments) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Staging', + features: %w(environments deployments) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Production', + features: %w(deployments) + ), + IdeaToProductionStep.new( + metric: subject, + title: 'Feedback', + features: %w(projects_prometheus_active service_desk_issues) + ) + ] + end + + def average_percentage_score + cards.sum(&:percentage_score) / cards.size.to_f + end + end +end diff --git a/app/serializers/analytics_build_entity.rb b/app/serializers/analytics_build_entity.rb index a0db5b8f0f4..ad7ad020b03 100644 --- a/app/serializers/analytics_build_entity.rb +++ b/app/serializers/analytics_build_entity.rb @@ -25,7 +25,7 @@ class AnalyticsBuildEntity < Grape::Entity end expose :url do |build| - url_to(:namespace_project_build, build) + url_to(:namespace_project_job, build) end expose :commit_url do |build| diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb index 5e99204c658..301b718d060 100644 --- a/app/serializers/build_action_entity.rb +++ b/app/serializers/build_action_entity.rb @@ -6,7 +6,7 @@ class BuildActionEntity < Grape::Entity end expose :path do |build| - play_namespace_project_build_path( + play_namespace_project_job_path( build.project.namespace, build.project, build) diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb index 8b643d8e783..cb55c98f7c6 100644 --- a/app/serializers/build_artifact_entity.rb +++ b/app/serializers/build_artifact_entity.rb @@ -1,14 +1,39 @@ class BuildArtifactEntity < Grape::Entity include RequestAwareEntity - expose :name do |build| - build.name + expose :name do |job| + job.name end - expose :path do |build| - download_namespace_project_build_artifacts_path( - build.project.namespace, - build.project, - build) + expose :artifacts_expired?, as: :expired + expose :artifacts_expire_at, as: :expire_at + + expose :path do |job| + download_namespace_project_job_artifacts_path( + project.namespace, + project, + job) + end + + expose :keep_path, if: -> (*) { job.has_expiring_artifacts? } do |job| + keep_namespace_project_job_artifacts_path( + project.namespace, + project, + job) + end + + expose :browse_path do |job| + browse_namespace_project_job_artifacts_path( + project.namespace, + project, + job) + end + + private + + alias_method :job, :object + + def project + job.project end end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb new file mode 100644 index 00000000000..0063920e603 --- /dev/null +++ b/app/serializers/build_details_entity.rb @@ -0,0 +1,50 @@ +class BuildDetailsEntity < BuildEntity + expose :coverage, :erased_at, :duration + expose :tag_list, as: :tags + + expose :user, using: UserEntity + + expose :erased_by, if: -> (*) { build.erased? }, using: UserEntity + expose :erase_path, if: -> (*) { build.erasable? && can?(current_user, :update_build, project) } do |build| + erase_namespace_project_job_path(project.namespace, project, build) + end + + expose :artifacts, using: BuildArtifactEntity + expose :runner, using: RunnerEntity + expose :pipeline, using: PipelineEntity + + expose :merge_request, if: -> (*) { can?(current_user, :read_merge_request, build.merge_request) } do + expose :iid do |build| + build.merge_request.iid + end + + expose :path do |build| + namespace_project_merge_request_path(project.namespace, project, build.merge_request) + end + end + + expose :new_issue_path, if: -> (*) { can?(request.current_user, :create_issue, project) && build.failed? } do |build| + new_namespace_project_issue_path(project.namespace, project, issue: build_failed_issue_options) + end + + expose :raw_path do |build| + raw_namespace_project_build_path(project.namespace, project, build) + end + + private + + def build_failed_issue_options + { + title: "Build Failed ##{build.id}", + description: namespace_project_job_url(project.namespace, project, build) + } + end + + def current_user + request.current_user + end + + def project + build.project + end +end diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb index e2276808b90..c01efa9dd5c 100644 --- a/app/serializers/build_entity.rb +++ b/app/serializers/build_entity.rb @@ -5,15 +5,15 @@ class BuildEntity < Grape::Entity expose :name expose :build_path do |build| - path_to(:namespace_project_build, build) + path_to(:namespace_project_job, build) end - expose :retry_path do |build| - path_to(:retry_namespace_project_build, build) + expose :retry_path, if: -> (*) { build&.retryable? } do |build| + path_to(:retry_namespace_project_job, build) end expose :play_path, if: -> (*) { playable? } do |build| - path_to(:play_namespace_project_build, build) + path_to(:play_namespace_project_job, build) end expose :playable?, as: :playable diff --git a/app/serializers/entity_date_helper.rb b/app/serializers/entity_date_helper.rb index 9607ad55a8b..71d9a65fb58 100644 --- a/app/serializers/entity_date_helper.rb +++ b/app/serializers/entity_date_helper.rb @@ -4,7 +4,7 @@ module EntityDateHelper def interval_in_words(diff) return 'Not started' unless diff - "#{distance_of_time_in_words(Time.now, diff)} ago" + distance_of_time_in_words(Time.now, diff, scope: 'datetime.time_ago_in_words') end # Converts seconds into a hash such as: diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index bc4f68710b2..35df95549b7 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -1,4 +1,6 @@ class IssueEntity < IssuableEntity + include RequestAwareEntity + expose :branch_name expose :confidential expose :assignees, using: API::Entities::UserBasic @@ -7,4 +9,8 @@ class IssueEntity < IssuableEntity expose :project_id expose :milestone, using: API::Entities::Milestone expose :labels, using: LabelEntity + + expose :web_url do |issue| + namespace_project_issue_path(issue.project.namespace, issue.project, issue) + end end diff --git a/app/serializers/merge_request_entity.rb b/app/serializers/merge_request_entity.rb index b3247ae36dd..7bb981041cc 100644 --- a/app/serializers/merge_request_entity.rb +++ b/app/serializers/merge_request_entity.rb @@ -29,7 +29,7 @@ class MergeRequestEntity < IssuableEntity expose :merge_commit_sha expose :merge_commit_message - expose :head_pipeline, with: PipelineEntity, as: :pipeline + expose :head_pipeline, with: PipelineDetailsEntity, as: :pipeline # Booleans expose :work_in_progress?, as: :work_in_progress @@ -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/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb new file mode 100644 index 00000000000..d58572a5f87 --- /dev/null +++ b/app/serializers/pipeline_details_entity.rb @@ -0,0 +1,7 @@ +class PipelineDetailsEntity < PipelineEntity + expose :details do + expose :stages, using: StageEntity + expose :artifacts, using: BuildArtifactEntity + expose :manual_actions, using: BuildActionEntity + end +end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index ea57cc97a7e..6d1fd9d459f 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -5,6 +5,9 @@ class PipelineEntity < Grape::Entity expose :user, using: UserEntity expose :active?, as: :active expose :coverage + expose :source + + expose :created_at, :updated_at expose :path do |pipeline| namespace_project_pipeline_path( @@ -13,24 +16,20 @@ class PipelineEntity < Grape::Entity pipeline) end - expose :details do - expose :detailed_status, as: :status, with: StatusEntity - expose :duration - expose :finished_at - expose :stages, using: StageEntity - expose :artifacts, using: BuildArtifactEntity - expose :manual_actions, using: BuildActionEntity - end - expose :flags do expose :latest?, as: :latest - expose :triggered?, as: :triggered expose :stuck?, as: :stuck expose :has_yaml_errors?, as: :yaml_errors expose :can_retry?, as: :retryable expose :can_cancel?, as: :cancelable end + expose :details do + expose :detailed_status, as: :status, with: StatusEntity + expose :duration + expose :finished_at + end + expose :ref do expose :name do |pipeline| pipeline.ref @@ -47,7 +46,6 @@ class PipelineEntity < Grape::Entity end expose :commit, using: CommitEntity - expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? } expose :retry_path, if: -> (*) { can_retry? } do |pipeline| retry_namespace_project_pipeline_path(pipeline.project.namespace, @@ -61,7 +59,7 @@ class PipelineEntity < Grape::Entity pipeline.id) end - expose :created_at, :updated_at + expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? } private diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index e37af63774c..b428ff69fe8 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -1,7 +1,7 @@ class PipelineSerializer < BaseSerializer InvalidResourceError = Class.new(StandardError) - entity PipelineEntity + entity PipelineDetailsEntity def with_pagination(request, response) tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) } diff --git a/app/serializers/runner_entity.rb b/app/serializers/runner_entity.rb new file mode 100644 index 00000000000..ed7dacc2dbd --- /dev/null +++ b/app/serializers/runner_entity.rb @@ -0,0 +1,18 @@ +class RunnerEntity < Grape::Entity + include RequestAwareEntity + + expose :id, :description + + expose :edit_path, + if: -> (*) { can?(request.current_user, :admin_build, project) && runner.specific? } do |runner| + edit_namespace_project_runner_path(project.namespace, project, runner) + end + + private + + alias_method :runner, :object + + def project + request.project + end +end diff --git a/app/serializers/user_entity.rb b/app/serializers/user_entity.rb index 43754ea94f7..876512b12dc 100644 --- a/app/serializers/user_entity.rb +++ b/app/serializers/user_entity.rb @@ -1,2 +1,7 @@ class UserEntity < API::Entities::UserBasic + include RequestAwareEntity + + expose :path do |user| + user_path(user) + end end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 1f6c1f4a7f6..13baa63220d 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -2,8 +2,9 @@ module Ci class CreatePipelineService < BaseService attr_reader :pipeline - def execute(ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil) + def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil) @pipeline = Ci::Pipeline.new( + source: source, project: project, ref: ref, sha: sha, @@ -61,6 +62,13 @@ module Ci private + def update_merge_requests_head_pipeline + return unless pipeline.latest? + + MergeRequest.where(source_project: @pipeline.project, source_branch: @pipeline.ref). + 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 +126,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/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb index 8362f01ddb8..beb27a5a597 100644 --- a/app/services/ci/create_trigger_request_service.rb +++ b/app/services/ci/create_trigger_request_service.rb @@ -4,7 +4,7 @@ module Ci trigger_request = trigger.trigger_requests.create(variables: variables) pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: ref). - execute(ignore_skip_ci: true, trigger_request: trigger_request) + execute(:trigger, ignore_skip_ci: true, trigger_request: trigger_request) trigger_request if pipeline.persisted? end diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb index 47f9b2c621c..46823418bb0 100644 --- a/app/services/create_deployment_service.rb +++ b/app/services/create_deployment_service.rb @@ -1,71 +1,59 @@ -class CreateDeploymentService < BaseService - def execute(deployable = nil) +class CreateDeploymentService + attr_reader :job + + delegate :expanded_environment_name, + :environment_url, + :project, + to: :job + + def initialize(job) + @job = job + end + + def execute return unless executable? ActiveRecord::Base.transaction do - @deployable = deployable + environment.external_url = environment_url if environment_url + environment.fire_state_event(action) - @environment = environment - @environment.external_url = expanded_url if expanded_url - @environment.fire_state_event(action) + return unless environment.save + return if environment.stopped? - return unless @environment.save - return if @environment.stopped? - - deploy.tap do |deployment| - deployment.update_merge_request_metrics! - end + deploy.tap(&:update_merge_request_metrics!) end end private def executable? - project && name.present? + project && job.environment.present? && environment end def deploy project.deployments.create( - environment: @environment, - ref: params[:ref], - tag: params[:tag], - sha: params[:sha], - user: current_user, - deployable: @deployable, - on_stop: options[:on_stop]) + environment: environment, + ref: job.ref, + tag: job.tag, + sha: job.sha, + user: job.user, + deployable: job, + on_stop: on_stop) end def environment - @environment ||= project.environments.find_or_create_by(name: expanded_name) - end - - def expanded_name - ExpandVariables.expand(name, variables) - end - - def expanded_url - return unless url - - @expanded_url ||= ExpandVariables.expand(url, variables) - end - - def name - params[:environment] - end - - def url - options[:url] + @environment ||= job.persisted_environment end - def options - params[:options] || {} + def environment_options + @environment_options ||= job.options&.dig(:environment) || {} end - def variables - params[:variables] || [] + def on_stop + environment_options[:on_stop] end def action - options[:action] || 'start' + environment_options[:action] || 'start' end end diff --git a/app/services/discussions/update_diff_position_service.rb b/app/services/discussions/update_diff_position_service.rb new file mode 100644 index 00000000000..1ef8d9edbe1 --- /dev/null +++ b/app/services/discussions/update_diff_position_service.rb @@ -0,0 +1,41 @@ +module Discussions + class UpdateDiffPositionService < BaseService + def execute(discussion) + result = tracer.trace(discussion.position) + return unless result + + position = result[:position] + outdated = result[:outdated] + + discussion.notes.each do |note| + if outdated + note.change_position = position + else + note.position = position + note.change_position = nil + end + end + + Note.transaction do + discussion.notes.each do |note| + Gitlab::Timeless.timeless(note, &:save) + end + + if outdated && current_user + SystemNoteService.diff_discussion_outdated(discussion, project, current_user, position) + end + end + end + + private + + def tracer + @tracer ||= Gitlab::Diff::PositionTracer.new( + project: project, + old_diff_refs: params[:old_diff_refs], + new_diff_refs: params[:new_diff_refs], + paths: params[:paths] + ) + end + end +end diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index d22236b961b..f080e6326a1 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -106,7 +106,7 @@ class GitPushService < BaseService EventCreateService.new.push(@project, current_user, build_push_data) @project.execute_hooks(build_push_data.dup, :push_hooks) @project.execute_services(build_push_data.dup, :push_hooks) - Ci::CreatePipelineService.new(@project, current_user, build_push_data).execute + Ci::CreatePipelineService.new(@project, current_user, build_push_data).execute(:push) if push_remove_branch? AfterBranchDeleteService diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb index 96432837481..7c424fba428 100644 --- a/app/services/git_tag_push_service.rb +++ b/app/services/git_tag_push_service.rb @@ -11,7 +11,7 @@ class GitTagPushService < BaseService SystemHooksService.new.execute_hooks(build_system_push_data.dup, :tag_push_hooks) project.execute_hooks(@push_data.dup, :tag_push_hooks) project.execute_services(@push_data.dup, :tag_push_hooks) - Ci::CreatePipelineService.new(project, current_user, @push_data).execute + Ci::CreatePipelineService.new(project, current_user, @push_data).execute(:push) ProjectCacheWorker.perform_async(project.id, [], [:commit_count, :repository_size]) true diff --git a/app/services/gravatar_service.rb b/app/services/gravatar_service.rb index 433ecc2df32..e77e08aa380 100644 --- a/app/services/gravatar_service.rb +++ b/app/services/gravatar_service.rb @@ -1,15 +1,20 @@ class GravatarService include Gitlab::CurrentSettings - def execute(email, size = nil, scale = 2) - if current_application_settings.gravatar_enabled? && email.present? - size = 40 if size.nil? || size <= 0 + def execute(email, size = nil, scale = 2, username: nil) + return unless current_application_settings.gravatar_enabled? - sprintf gravatar_url, - hash: Digest::MD5.hexdigest(email.strip.downcase), - size: size * scale, - email: email.strip - end + identifier = email.presence || username.presence + return unless identifier + + hash = Digest::MD5.hexdigest(identifier.strip.downcase) + size = 40 unless size && size > 0 + + sprintf gravatar_url, + hash: hash, + size: size * scale, + email: ERB::Util.url_encode(email&.strip || ''), + username: ERB::Util.url_encode(username&.strip || '') end def gitlab_config diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index e94ab3e64db..e77a3e3eac1 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -148,7 +148,7 @@ class IssuableBaseService < BaseService execute(params[:description], issuable) # Avoid a description already set on an issuable to be overwritten by a nil - params[:description] = description if params.has_key?(:description) + params[:description] = description if params.key?(:description) params.merge!(command_params) end diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index f1030912c68..85c616ca576 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 40fbe354492..80ea6312768 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 f2053bda83a..2ffc989ed71 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/conflicts/resolve_service.rb b/app/services/merge_requests/conflicts/resolve_service.rb index d74a82effd6..c2c335b8461 100644 --- a/app/services/merge_requests/conflicts/resolve_service.rb +++ b/app/services/merge_requests/conflicts/resolve_service.rb @@ -37,11 +37,13 @@ module MergeRequests private def write_resolved_file_to_index(merge_index, rugged, file, params) - new_file = if params[:sections] - file.resolve_lines(params[:sections]).map(&:text).join("\n") - elsif params[:content] - file.resolve_content(params[:content]) - end + if params[:sections] + new_file = file.resolve_lines(params[:sections]).map(&:text).join("\n") + + new_file << "\n" if file.our_blob.data.ends_with?("\n") + elsif params[:content] + new_file = file.resolve_content(params[:content]) + end our_path = file.our_path diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index b0ae2dfe4ce..71d37797bb4 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,18 @@ 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_sha + return unless sha + + pipelines = merge_request.source_project.pipelines.where(ref: merge_request.source_branch, sha: sha) + + pipelines.order(id: :desc).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 e8fb1b59752..f0d998731d7 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 54b19e6d651..f2fddf7f345 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/notes/diff_position_update_service.rb b/app/services/notes/diff_position_update_service.rb deleted file mode 100644 index eff7b287269..00000000000 --- a/app/services/notes/diff_position_update_service.rb +++ /dev/null @@ -1,33 +0,0 @@ -module Notes - class DiffPositionUpdateService < BaseService - def execute(note) - results = tracer.trace(note.position) - return unless results - - position = results[:position] - outdated = results[:outdated] - - if outdated - note.change_position = position - - if note.persisted? && current_user - SystemNoteService.diff_discussion_outdated(note.to_discussion, project, current_user, position) - end - else - note.position = position - note.change_position = nil - end - end - - private - - def tracer - @tracer ||= Gitlab::Diff::PositionTracer.new( - project: project, - old_diff_refs: params[:old_diff_refs], - new_diff_refs: params[:new_diff_refs], - paths: params[:paths] - ) - end - end -end diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 06d8d143231..e2b2660ea71 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -7,11 +7,9 @@ module Projects DELETED_FLAG = '+deleted'.freeze def async_execute - project.transaction do - project.update_attribute(:pending_delete, true) - job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params) - Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.path_with_namespace} with job ID #{job_id}") - end + project.update_attribute(:pending_delete, true) + job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params) + Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.path_with_namespace} with job ID #{job_id}") end def execute @@ -62,7 +60,11 @@ module Projects if gitlab_shell.mv_repository(project.repository_storage_path, path, new_path) log_info("Repository \"#{path}\" moved to \"#{new_path}\"") - GitlabShellWorker.perform_in(5.minutes, :remove_repository, project.repository_storage_path, new_path) + + project.run_after_commit do + # self is now project + GitlabShellWorker.perform_in(5.minutes, :remove_repository, self.repository_storage_path, new_path) + end else false end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 22736c71725..1d4d03a8b7d 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/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb new file mode 100644 index 00000000000..17857ca62f2 --- /dev/null +++ b/app/services/submit_usage_ping_service.rb @@ -0,0 +1,41 @@ +class SubmitUsagePingService + URL = 'https://version.gitlab.com/usage_data'.freeze + + include Gitlab::CurrentSettings + + def execute + return false unless current_application_settings.usage_ping_enabled? + + response = HTTParty.post( + URL, + body: Gitlab::UsageData.to_json(force_refresh: true), + headers: { 'Content-type' => 'application/json' } + ) + + store_metrics(response) + + true + rescue HTTParty::Error => e + Rails.logger.info "Unable to contact GitLab, Inc.: #{e}" + + false + end + + private + + def store_metrics(response) + return unless response['conv_index'].present? + + ConversationalDevelopmentIndex::Metric.create!( + response['conv_index'].slice( + 'leader_issues', 'instance_issues', 'leader_notes', 'instance_notes', + 'leader_milestones', 'instance_milestones', 'leader_boards', 'instance_boards', + 'leader_merge_requests', 'instance_merge_requests', 'leader_ci_pipelines', + 'instance_ci_pipelines', 'leader_environments', 'instance_environments', + 'leader_deployments', 'instance_deployments', 'leader_projects_prometheus_active', + 'instance_projects_prometheus_active', 'leader_service_desk_issues', + 'instance_service_desk_issues' + ) + ) + end +end diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index 8f6f5b937c4..3e07b811027 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/services/web_hook_service.rb b/app/services/web_hook_service.rb new file mode 100644 index 00000000000..4241b912d5b --- /dev/null +++ b/app/services/web_hook_service.rb @@ -0,0 +1,120 @@ +class WebHookService + class InternalErrorResponse + attr_reader :body, :headers, :code + + def initialize + @headers = HTTParty::Response::Headers.new({}) + @body = '' + @code = 'internal error' + end + end + + include HTTParty + + # HTTParty timeout + default_timeout Gitlab.config.gitlab.webhook_timeout + + attr_accessor :hook, :data, :hook_name + + def initialize(hook, data, hook_name) + @hook = hook + @data = data + @hook_name = hook_name + end + + def execute + start_time = Time.now + + response = if parsed_url.userinfo.blank? + make_request(hook.url) + else + make_request_with_auth + end + + log_execution( + trigger: hook_name, + url: hook.url, + request_data: data, + response: response, + execution_duration: Time.now - start_time + ) + + [response.code, response.to_s] + rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout => e + log_execution( + trigger: hook_name, + url: hook.url, + request_data: data, + response: InternalErrorResponse.new, + execution_duration: Time.now - start_time, + error_message: e.to_s + ) + + Rails.logger.error("WebHook Error => #{e}") + + [nil, e.to_s] + end + + def async_execute + Sidekiq::Client.enqueue(WebHookWorker, hook.id, data, hook_name) + end + + private + + def parsed_url + @parsed_url ||= URI.parse(hook.url) + end + + def make_request(url, basic_auth = false) + self.class.post(url, + body: data.to_json, + headers: build_headers(hook_name), + verify: hook.enable_ssl_verification, + basic_auth: basic_auth) + end + + def make_request_with_auth + post_url = hook.url.gsub("#{parsed_url.userinfo}@", '') + basic_auth = { + username: CGI.unescape(parsed_url.user), + password: CGI.unescape(parsed_url.password) + } + make_request(post_url, basic_auth) + end + + def log_execution(trigger:, url:, request_data:, response:, execution_duration:, error_message: nil) + # logging for ServiceHook's is not available + return if hook.is_a?(ServiceHook) + + WebHookLog.create( + web_hook: hook, + trigger: trigger, + url: url, + execution_duration: execution_duration, + request_headers: build_headers(hook_name), + request_data: request_data, + response_headers: format_response_headers(response), + response_body: response.body, + response_status: response.code, + internal_error_message: error_message + ) + end + + def build_headers(hook_name) + @headers ||= begin + { + 'Content-Type' => 'application/json', + 'X-Gitlab-Event' => hook_name.singularize.titleize + }.tap do |hash| + hash['X-Gitlab-Token'] = hook.token if hook.token.present? + end + end + end + + # Make response headers more stylish + # Net::HTTPHeader has downcased hash with arrays: { 'content-type' => ['text/html; charset=utf-8'] } + # This method format response to capitalized hash with strings: { 'Content-Type' => 'text/html; charset=utf-8' } + def format_response_headers(response) + response.headers.each_capitalized.to_h + end +end diff --git a/app/uploaders/artifact_uploader.rb b/app/uploaders/artifact_uploader.rb index 3e36ec91205..3bc0408f557 100644 --- a/app/uploaders/artifact_uploader.rb +++ b/app/uploaders/artifact_uploader.rb @@ -1,33 +1,35 @@ class ArtifactUploader < GitlabUploader storage :file - attr_accessor :build, :field + attr_reader :job, :field - def self.artifacts_path + def self.local_artifacts_store Gitlab.config.artifacts.path end def self.artifacts_upload_path - File.join(self.artifacts_path, 'tmp/uploads/') + File.join(self.local_artifacts_store, 'tmp/uploads/') end - def self.artifacts_cache_path - File.join(self.artifacts_path, 'tmp/cache/') - end - - def initialize(build, field) - @build, @field = build, field + def initialize(job, field) + @job, @field = job, field end def store_dir - File.join(self.class.artifacts_path, @build.artifacts_path) + default_local_path end def cache_dir - File.join(self.class.artifacts_cache_path, @build.artifacts_path) + File.join(self.class.local_artifacts_store, 'tmp/cache') + end + + private + + def default_local_path + File.join(self.class.local_artifacts_store, default_path) end - def filename - file.try(:filename) + def default_path + File.join(job.created_at.utc.strftime('%Y_%m'), job.project_id.to_s, job.id.to_s) end end diff --git a/app/uploaders/gitlab_uploader.rb b/app/uploaders/gitlab_uploader.rb index e0a6c9b4067..02afddb8c6a 100644 --- a/app/uploaders/gitlab_uploader.rb +++ b/app/uploaders/gitlab_uploader.rb @@ -10,7 +10,11 @@ class GitlabUploader < CarrierWave::Uploader::Base delegate :base_dir, to: :class def file_storage? - self.class.storage == CarrierWave::Storage::File + storage.is_a?(CarrierWave::Storage::File) + end + + def file_cache_storage? + cache_storage.is_a?(CarrierWave::Storage::File) end # Reduce disk IO diff --git a/app/uploaders/lfs_object_uploader.rb b/app/uploaders/lfs_object_uploader.rb index 95a891111e1..02589959c2f 100644 --- a/app/uploaders/lfs_object_uploader.rb +++ b/app/uploaders/lfs_object_uploader.rb @@ -12,4 +12,20 @@ class LfsObjectUploader < GitlabUploader def filename model.oid[4..-1] end + + def work_dir + File.join(Gitlab.config.lfs.storage_path, 'tmp', 'work') + end + + private + + # To prevent LFS files from moving across filesystems, override the default + # implementation: + # http://github.com/carrierwaveuploader/carrierwave/blob/v1.0.0/lib/carrierwave/uploader/cache.rb#L181-L183 + def workfile_path(for_file = original_filename) + # To be safe, keep this directory outside of the the cache directory + # because calling CarrierWave.clean_cache_files! will remove any files in + # the cache directory. + File.join(work_dir, @cache_id, version_name.to_s, for_file) + end end diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb index 8d4d7180baf..27ac60637fd 100644 --- a/app/validators/dynamic_path_validator.rb +++ b/app/validators/dynamic_path_validator.rb @@ -3,16 +3,25 @@ # Custom validator for GitLab path values. # These paths are assigned to `Namespace` (& `Group` as a subclass) & `Project` # -# Values are checked for formatting and exclusion from a list of reserved path +# Values are checked for formatting and exclusion from a list of illegal path # names. class DynamicPathValidator < ActiveModel::EachValidator + extend Gitlab::EncodingHelper + class << self - def valid_namespace_path?(path) - "#{path}/" =~ Gitlab::Regex.full_namespace_path_regex + 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) - "#{path}/" =~ Gitlab::Regex.full_project_path_regex + encode!(path) + "#{path}/" =~ Gitlab::PathRegex.full_project_path_regex end end @@ -24,14 +33,16 @@ class DynamicPathValidator < ActiveModel::EachValidator case record when Project self.class.valid_project_path?(full_path) - else - self.class.valid_namespace_path?(full_path) + when Group + self.class.valid_group_path?(full_path) + else # User or non-Group Namespace + self.class.valid_user_path?(full_path) end end def validate_each(record, attribute, value) - unless value =~ Gitlab::Regex.namespace_regex - record.errors.add(attribute, Gitlab::Regex.namespace_regex_message) + unless value =~ Gitlab::PathRegex.namespace_format_regex + record.errors.add(attribute, Gitlab::PathRegex.namespace_format_message) return end diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml index ac36bb5bb17..e5842bd1ea0 100644 --- a/app/views/admin/background_jobs/show.html.haml +++ b/app/views/admin/background_jobs/show.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title "Background Jobs" -= render 'admin/background_jobs/head' += render 'admin/monitoring/head' %div{ class: container_class } %h3.page-title Background Jobs diff --git a/app/views/admin/conversational_development_index/_callout.html.haml b/app/views/admin/conversational_development_index/_callout.html.haml new file mode 100644 index 00000000000..33a4dab1e00 --- /dev/null +++ b/app/views/admin/conversational_development_index/_callout.html.haml @@ -0,0 +1,13 @@ +.prepend-top-default +.user-callout{ data: { uid: 'convdev_intro_callout_dismissed' } } + .bordered-box.landing.content-block + %button.btn.btn-default.close.js-close-callout{ type: 'button', + 'aria-label' => 'Dismiss ConvDev introduction' } + = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true') + .user-callout-copy + %h4 + Introducing Your Conversational Development Index + %p + Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers. + .svg-container.convdev + = custom_icon('convdev_overview') diff --git a/app/views/admin/conversational_development_index/_card.html.haml b/app/views/admin/conversational_development_index/_card.html.haml new file mode 100644 index 00000000000..6c8688e06ae --- /dev/null +++ b/app/views/admin/conversational_development_index/_card.html.haml @@ -0,0 +1,25 @@ +.convdev-card-wrapper + .convdev-card{ class: "convdev-card-#{score_level(card.percentage_score)}" } + .convdev-card-title + %h3 + = card.title + .text-light + = card.description + .card-scores + .card-score + .card-score-value + = format_score(card.instance_score) + .card-score-name You + .card-score + .card-score-value + = format_score(card.leader_score) + .card-score-name Lead + .card-score-big + = number_to_percentage(card.percentage_score, precision: 1) + .card-buttons + - if card.blog + %a{ href: card.blog } + = icon('info-circle', 'aria-hidden' => 'true') + - if card.docs + %a{ href: card.docs } + = icon('question-circle', 'aria-hidden' => 'true') diff --git a/app/views/admin/conversational_development_index/_disabled.html.haml b/app/views/admin/conversational_development_index/_disabled.html.haml new file mode 100644 index 00000000000..975d7df3da6 --- /dev/null +++ b/app/views/admin/conversational_development_index/_disabled.html.haml @@ -0,0 +1,9 @@ +.container.convdev-empty + .col-sm-6.col-sm-push-3.text-center + = custom_icon('convdev_no_index') + %h4 Usage ping is not enabled + %p + ConvDev is only shown when the + = link_to 'usage ping', help_page_path('user/admin_area/settings/usage_statistics'), target: '_blank' + is enabled. Enable usage ping to get an overview of how you are using GitLab from a feature perspective + = link_to 'Enable usage ping', admin_application_settings_path(anchor: 'usage-statistics'), class: 'btn btn-primary' diff --git a/app/views/admin/conversational_development_index/_no_data.html.haml b/app/views/admin/conversational_development_index/_no_data.html.haml new file mode 100644 index 00000000000..b23d2b5ec3a --- /dev/null +++ b/app/views/admin/conversational_development_index/_no_data.html.haml @@ -0,0 +1,7 @@ +.container.convdev-empty + .col-sm-6.col-sm-push-3.text-center + = custom_icon('convdev_no_data') + %h4 Data is still calculating... + %p + In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index. + = link_to 'Learn more', help_page_path('user/admin_area/monitoring/convdev'), target: '_blank' diff --git a/app/views/admin/conversational_development_index/show.html.haml b/app/views/admin/conversational_development_index/show.html.haml new file mode 100644 index 00000000000..833d4c612f8 --- /dev/null +++ b/app/views/admin/conversational_development_index/show.html.haml @@ -0,0 +1,35 @@ +- @no_container = true +- page_title 'ConvDev Index' + += render 'admin/monitoring/head' + +.container + - if show_callout?('convdev_intro_callout_dismissed') + = render 'callout' + + .prepend-top-default + - if !current_application_settings.usage_ping_enabled + = render 'disabled' + - elsif @metric.blank? + = render 'no_data' + - else + .convdev + .convdev-header + %h2.convdev-header-title{ class: "convdev-#{score_level(@metric.average_percentage_score)}-score" } + = number_to_percentage(@metric.average_percentage_score, precision: 1) + .convdev-header-subtitle + index + %br + score + = link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/admin_area/monitoring/convdev') + + .convdev-cards.card-container + - @metric.cards.each do |card| + = render 'card', card: card + + .convdev-steps.visible-lg + - @metric.idea_to_production_steps.each_with_index do |step, index| + .convdev-step{ class: "convdev-#{score_level(step.percentage_score)}-score" } + = custom_icon("i2p_step_#{index + 1}") + %h4.convdev-step-title + = step.title diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml index 163bd5662b0..dff549f502c 100644 --- a/app/views/admin/dashboard/_head.html.haml +++ b/app/views/admin/dashboard/_head.html.haml @@ -20,7 +20,7 @@ %span Groups = nav_link path: 'builds#index' do - = link_to admin_builds_path, title: 'Jobs' do + = link_to admin_jobs_path, title: 'Jobs' do %span Jobs = nav_link path: ['runners#index', 'runners#show'] do diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 53f0a1e7fde..3c9f932a225 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -79,6 +79,12 @@ = gitlab_pages %span.light.pull-right = boolean_to_icon gitlab_pages_enabled + - gitlab_shared_runners = 'Shared Runners' + - gitlab_shared_runners_enabled = Gitlab.config.gitlab_ci.shared_runners_enabled + %p{ "aria-label" => "#{gitlab_shared_runners}: status " + (gitlab_shared_runners_enabled ? "on" : "off") } + = gitlab_shared_runners + %span.light.pull-right + = boolean_to_icon gitlab_shared_runners_enabled .col-md-4 %h4 diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index 4deccf4aa93..8adb966064c 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title "Health Check" -= render 'admin/background_jobs/head' += render 'admin/monitoring/head' %div{ class: container_class } %h3.page-title diff --git a/app/views/admin/hook_logs/_index.html.haml b/app/views/admin/hook_logs/_index.html.haml new file mode 100644 index 00000000000..7dd9943190f --- /dev/null +++ b/app/views/admin/hook_logs/_index.html.haml @@ -0,0 +1,37 @@ +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + Recent Deliveries + %p When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong. + .col-lg-9 + - if hook_logs.any? + %table.table + %thead + %tr + %th Status + %th Trigger + %th URL + %th Elapsed time + %th Request time + %th + - hook_logs.each do |hook_log| + %tr + %td + = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log } + %td.hidden-xs + %span.label.label-gray.deploy-project-label + = hook_log.trigger.singularize.titleize + %td + = truncate(hook_log.url, length: 50) + %td.light + #{number_with_precision(hook_log.execution_duration, precision: 2)} ms + %td.light + = time_ago_with_tooltip(hook_log.created_at) + %td + = link_to 'View details', admin_hook_hook_log_path(hook, hook_log) + + = paginate hook_logs, theme: 'gitlab' + + - else + .settings-message.text-center + You don't have any webhooks deliveries diff --git a/app/views/admin/hook_logs/show.html.haml b/app/views/admin/hook_logs/show.html.haml new file mode 100644 index 00000000000..56127bacda2 --- /dev/null +++ b/app/views/admin/hook_logs/show.html.haml @@ -0,0 +1,10 @@ +- page_title 'Request details' +%h3.page-title + Request details + +%hr + += link_to 'Resend Request', retry_admin_hook_hook_log_path(@hook, @hook_log), class: "btn btn-default pull-right prepend-left-10" + += render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log } + diff --git a/app/views/admin/hooks/edit.html.haml b/app/views/admin/hooks/edit.html.haml index 0777f5e2629..0e35a1905bf 100644 --- a/app/views/admin/hooks/edit.html.haml +++ b/app/views/admin/hooks/edit.html.haml @@ -12,3 +12,9 @@ = render partial: 'form', locals: { form: f, hook: @hook } .form-actions = f.submit 'Save changes', class: 'btn btn-create' + = link_to 'Test hook', test_admin_hook_path(@hook), class: 'btn btn-default' + = link_to 'Remove', admin_hook_path(@hook), method: :delete, class: 'btn btn-remove pull-right', data: { confirm: 'Are you sure?' } + +%hr + += render partial: 'admin/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs } diff --git a/app/views/admin/builds/index.html.haml b/app/views/admin/jobs/index.html.haml index 66d633119c2..09be17f07be 100644 --- a/app/views/admin/builds/index.html.haml +++ b/app/views/admin/jobs/index.html.haml @@ -4,15 +4,15 @@ %div{ class: container_class } .top-area - - build_path_proc = ->(scope) { admin_builds_path(scope: scope) } + - build_path_proc = ->(scope) { admin_jobs_path(scope: scope) } = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope .nav-controls - if @all_builds.running_or_pending.any? - = link_to 'Cancel all', cancel_all_admin_builds_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post + = link_to 'Cancel all', cancel_all_admin_jobs_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post .row-content-block.second-block #{(@scope || 'all').capitalize} jobs %ul.content-list.builds-content-list.admin-builds-table - = render "projects/builds/table", builds: @builds, admin: true + = render "projects/jobs/table", builds: @builds, admin: true diff --git a/app/views/admin/logs/show.html.haml b/app/views/admin/logs/show.html.haml index 5e585ce789b..487f1cf5c4f 100644 --- a/app/views/admin/logs/show.html.haml +++ b/app/views/admin/logs/show.html.haml @@ -3,7 +3,7 @@ - loggers = [Gitlab::GitLogger, Gitlab::AppLogger, Gitlab::EnvironmentLogger, Gitlab::SidekiqLogger, Gitlab::RepositoryCheckLogger] -= render 'admin/background_jobs/head' += render 'admin/monitoring/head' %div{ class: container_class } %ul.nav-links.log-tabs diff --git a/app/views/admin/background_jobs/_head.html.haml b/app/views/admin/monitoring/_head.html.haml index b3530915068..901e30275fd 100644 --- a/app/views/admin/background_jobs/_head.html.haml +++ b/app/views/admin/monitoring/_head.html.haml @@ -3,6 +3,10 @@ = render 'shared/nav_scroll' .nav-links.sub-nav.scrolling-tabs %ul{ class: (container_class) } + = nav_link(controller: :conversational_development_index) do + = link_to admin_conversational_development_index_path, title: 'ConvDev Index' do + %span + ConvDev Index = nav_link(controller: :system_info) do = link_to admin_system_info_path, title: 'System Info' do %span diff --git a/app/views/admin/requests_profiles/index.html.haml b/app/views/admin/requests_profiles/index.html.haml index c7b63d9de98..b7db18b2d32 100644 --- a/app/views/admin/requests_profiles/index.html.haml +++ b/app/views/admin/requests_profiles/index.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title 'Requests Profiles' -= render 'admin/background_jobs/head' += render 'admin/monitoring/head' %div{ class: container_class } %h3.page-title diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index dc4116e1ce0..801430e525e 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -85,7 +85,7 @@ %tr.build %td.id - if project - = link_to namespace_project_build_path(project.namespace, project, build) do + = link_to namespace_project_job_path(project.namespace, project, build) do %strong ##{build.id} - else %strong ##{build.id} diff --git a/app/views/admin/system_info/show.html.haml b/app/views/admin/system_info/show.html.haml index 2e5f120c4e4..fd0281e4961 100644 --- a/app/views/admin/system_info/show.html.haml +++ b/app/views/admin/system_info/show.html.haml @@ -1,6 +1,6 @@ - @no_container = true - page_title "System Info" -= render 'admin/background_jobs/head' += render 'admin/monitoring/head' %div{ class: container_class } .prepend-top-default @@ -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/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml index 8862455688f..46d2e3b3de1 100644 --- a/app/views/admin/users/_user.html.haml +++ b/app/views/admin/users/_user.html.haml @@ -34,7 +34,7 @@ - if user.access_locked? %li = link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' } - - if user.can_be_removed? && can?(current_user, :destroy_user, @user) + - if user.can_be_removed? && can?(current_user, :destroy_user, user) %li.divider %li = link_to 'Delete user', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Consider cancelling this deletion and blocking the user instead. Are you sure?" }, diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml index e1b270a08c2..a676eba2aee 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 190ad4b40a5..f893c3e1675 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 faa68468043..d6b46dee0e4 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 12966c01950..6f6afe161d1 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 596499230f9..5e63a61e21b 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 } + - if show_callout?('user_callout_dismissed') + = 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 162ae153b1c..99efe9c9b86 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/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index a2f6a7ab1cb..d696577278d 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -8,7 +8,7 @@ = f.text_field :name, class: "form-control top", required: true, title: "This field is required." .username.form-group = f.label :username - = f.text_field :username, class: "form-control middle", pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_JS, required: true, title: 'Please create a username with only alphanumeric characters.' + = f.text_field :username, class: "form-control middle", pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: 'Please create a username with only alphanumeric characters.' %p.validation-error.hide Username is already taken. %p.validation-success.hide Username is available. %p.validation-pending.hide Checking username availability... diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml index c3f55ff821f..70042dee20f 100644 --- a/app/views/discussions/_diff_with_notes.html.haml +++ b/app/views/discussions/_diff_with_notes.html.haml @@ -3,7 +3,7 @@ .diff-file.file-holder .js-file-title.file-title - = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_file.content_commit, project: discussion.project, url: discussion_path(discussion), show_toggle: false + = render "projects/diffs/file_header", diff_file: diff_file, url: discussion_path(discussion), show_toggle: false .diff-content.code.js-syntax-highlight %table diff --git a/app/views/discussions/_jump_to_next.html.haml b/app/views/discussions/_jump_to_next.html.haml index 69bd416c4de..3db509f24a5 100644 --- a/app/views/discussions/_jump_to_next.html.haml +++ b/app/views/discussions/_jump_to_next.html.haml @@ -3,7 +3,7 @@ %jump-to-discussion{ "inline-template" => true, ":discussion-id" => "'#{discussion.try(:id)}'" } .btn-group{ role: "group", "v-show" => "!allResolved", "v-if" => "showButton" } %button.btn.btn-default.discussion-next-btn.has-tooltip{ "@click" => "jumpToNextUnresolvedDiscussion", - title: "Jump to next unresolved discussion", - "aria-label" => "Jump to next unresolved discussion", + ":title" => "buttonText", + ":aria-label" => "buttonText", data: { container: "body" } } = custom_icon("next_discussion") diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index 7ba3f3f6c42..db5ab939948 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 1584695a62b..00000000000 --- 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 c0943100ae3..769ac655d0a 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 d7851c79990..fd6e7111f38 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 873504099d4..0f63774fb9b 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 b2097e88741..35b75bc0923 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 18997baa998..80a8ba4a755 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 d068c895fa3..6df0adfd742 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -5,19 +5,19 @@ .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 - = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles)) do - = link_to admin_system_info_path, title: 'Monitoring' do + = nav_link(controller: %w(conversational_development_index system_info background_jobs logs health_check requests_profiles)) do + = link_to admin_conversational_development_index_path, title: 'Monitoring' do %span Monitoring = nav_link(controller: :broadcast_messages) do = link_to admin_broadcast_messages_path, title: 'Messages' do %span Messages - = nav_link(controller: :hooks) do + = nav_link(controller: [:hooks, :hook_logs]) do = link_to admin_hooks_path, title: 'Hooks' do %span System Hooks diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index e4dfe0c8c08..29658da7792 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -92,7 +92,7 @@ -# Shortcut to Pipelines > Jobs - if project_nav_tab? :builds %li.hidden - = link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do + = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do Jobs -# Shortcut to commits page diff --git a/app/views/notify/links/ci/builds/_build.html.haml b/app/views/notify/links/ci/builds/_build.html.haml index d35b3839171..644cf506eff 100644 --- a/app/views/notify/links/ci/builds/_build.html.haml +++ b/app/views/notify/links/ci/builds/_build.html.haml @@ -1,2 +1,2 @@ -%a{ href: pipeline_build_url(pipeline, build), style: "color:#3777b0;text-decoration:none;" } +%a{ href: pipeline_job_url(pipeline, build), style: "color:#3777b0;text-decoration:none;" } = build.name diff --git a/app/views/notify/links/ci/builds/_build.text.erb b/app/views/notify/links/ci/builds/_build.text.erb index 741c7f344c8..773ae8174e9 100644 --- a/app/views/notify/links/ci/builds/_build.text.erb +++ b/app/views/notify/links/ci/builds/_build.text.erb @@ -1 +1 @@ -Job #<%= build.id %> ( <%= pipeline_build_url(pipeline, build) %> ) +Job #<%= build.id %> ( <%= pipeline_job_url(pipeline, build) %> ) diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml index 02eb7c8462c..546376aeed8 100644 --- a/app/views/notify/repository_push_email.html.haml +++ b/app/views/notify/repository_push_email.html.haml @@ -27,40 +27,38 @@ %h4 #{pluralize @message.diffs_count, "changed file"}: %ul - - @message.diffs.each do |diff| + - @message.diffs.each do |diff_file| %li.file-stats - %a{ href: "#{@message.target_url if @message.disable_diffs?}##{hexdigest(diff.file_path)}" } - - if diff.deleted_file + %a{ href: "#{@message.target_url if @message.disable_diffs?}##{hexdigest(diff_file.file_path)}" } + - if diff_file.deleted_file? %span.deleted-file − - = diff.old_path - - elsif diff.renamed_file - = diff.old_path + = diff_file.old_path + - elsif diff_file.renamed_file? + = diff_file.old_path → - = diff.new_path - - elsif diff.new_file + = diff_file.new_path + - elsif diff_file.new_file? %span.new-file + - = diff.new_path + = diff_file.new_path - else - = diff.new_path + = diff_file.new_path - unless @message.disable_diffs? - - diff_files = @message.diffs - - if @message.compare_timeout %h5 The diff was not included because it is too large. - else %h4 Changes: - - diff_files.each do |diff_file| + - @message.diffs.each do |diff_file| - file_hash = hexdigest(diff_file.file_path) %li{ id: file_hash } %a{ href: @message.target_url + "##{file_hash}" }< - - if diff_file.deleted_file + - if diff_file.deleted_file? %strong< = diff_file.old_path deleted - - elsif diff_file.renamed_file + - elsif diff_file.renamed_file? %strong< = diff_file.old_path → diff --git a/app/views/notify/repository_push_email.text.haml b/app/views/notify/repository_push_email.text.haml index 5ac23aa3997..895d8807e47 100644 --- a/app/views/notify/repository_push_email.text.haml +++ b/app/views/notify/repository_push_email.text.haml @@ -15,15 +15,15 @@ \ #{pluralize @message.diffs_count, "changed file"}: \ - - @message.diffs.each do |diff| - - if diff.deleted_file - \- − #{diff.old_path} - - elsif diff.renamed_file - \- #{diff.old_path} → #{diff.new_path} - - elsif diff.new_file - \- + #{diff.new_path} + - @message.diffs.each do |diff_file| + - if diff_file.deleted_file? + \- − #{diff_file.old_path} + - elsif diff_file.renamed_file? + \- #{diff_file.old_path} → #{diff_file.new_path} + - elsif diff_file.new_file? + \- + #{diff_file.new_path} - else - \- #{diff.new_path} + \- #{diff_file.new_path} - unless @message.disable_diffs? - if @message.compare_timeout \ @@ -36,9 +36,9 @@ - @message.diffs.each do |diff_file| \ \===================================== - - if diff_file.deleted_file + - if diff_file.deleted_file? #{diff_file.old_path} deleted - - elsif diff_file.renamed_file + - elsif diff_file.renamed_file? #{diff_file.old_path} → #{diff_file.new_path} - else = diff_file.new_path diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index f5bb7364d4a..10f581d751b 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 f8a6e98d280..e8b1940af2d 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 27c8e3c7fca..ef8d8051cbf 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/artifacts/_tree_directory.html.haml b/app/views/projects/artifacts/_tree_directory.html.haml index 34d5c3b7285..e2966ec33c2 100644 --- a/app/views/projects/artifacts/_tree_directory.html.haml +++ b/app/views/projects/artifacts/_tree_directory.html.haml @@ -1,4 +1,4 @@ -- path_to_directory = browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: directory.path) +- path_to_directory = browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: directory.path) %tr.tree-item{ 'data-link' => path_to_directory } %td.tree-item-file-name diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml index ce7e25d774b..ea0b43b85cf 100644 --- a/app/views/projects/artifacts/_tree_file.html.haml +++ b/app/views/projects/artifacts/_tree_file.html.haml @@ -1,4 +1,4 @@ -- path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path) +- path_to_file = file_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path: file.path) %tr.tree-item{ 'data-link' => path_to_file } - blob = file.blob diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml index 9fbb30f7c7c..961c805dc7c 100644 --- a/app/views/projects/artifacts/browse.html.haml +++ b/app/views/projects/artifacts/browse.html.haml @@ -1,22 +1,22 @@ - page_title @path.presence, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' = render "projects/pipelines/head" -= render "projects/builds/header", show_controls: false += render "projects/jobs/header", show_controls: false .tree-holder .nav-block .tree-controls - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), + = link_to download_namespace_project_job_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', class: 'btn btn-default download' do = icon('download') Download artifacts archive %ul.breadcrumb.repo-breadcrumb %li - = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + = link_to 'Artifacts', browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build) - path_breadcrumbs do |title, path| %li - = link_to truncate(title, length: 40), browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + = link_to truncate(title, length: 40), browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path) .tree-content-holder %table.table.tree-table diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml index d8da83b9a80..b25c7c95196 100644 --- a/app/views/projects/artifacts/file.html.haml +++ b/app/views/projects/artifacts/file.html.haml @@ -1,21 +1,21 @@ - page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' = render "projects/pipelines/head" -= render "projects/builds/header", show_controls: false += render "projects/jobs/header", show_controls: false #tree-holder.tree-holder .nav-block %ul.breadcrumb.repo-breadcrumb %li - = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + = link_to 'Artifacts', browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build) - path_breadcrumbs do |title, path| - title = truncate(title, length: 40) %li - if path == @path - = link_to file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) do + = link_to file_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path) do %strong= title - else - = link_to title, browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + = link_to title, browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build, path) %article.file-holder diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index a2ec3d44185..a6ee2b2f7b8 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -1,5 +1,5 @@ - @no_container = true -- page_title "Blame", @blob.path, @ref +- page_title "Annotate", @blob.path, @ref = render "projects/commits/head" %div{ class: container_class } diff --git a/app/views/projects/blob/_breadcrumb.html.haml b/app/views/projects/blob/_breadcrumb.html.haml index 3f58e8d232f..0ad9f258e48 100644 --- a/app/views/projects/blob/_breadcrumb.html.haml +++ b/app/views/projects/blob/_breadcrumb.html.haml @@ -10,7 +10,7 @@ = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id), class: 'btn' - else - = link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id), + = link_to 'Annotate', namespace_project_blame_path(@project.namespace, @project, @id), class: 'btn js-blob-blame-link' unless blob.empty? = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index 67f57b5e4b9..41f75a491a5 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 48f8c656080..e8db868f49b 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/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index a190a8760ef..d9f28d66b66 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -14,7 +14,7 @@ %td.branch-commit - if can?(current_user, :read_build, job) - = link_to namespace_project_build_url(job.project.namespace, job.project, job) do + = link_to namespace_project_job_url(job.project.namespace, job.project, job) do %span.build-link ##{job.id} - else %span.build-link ##{job.id} @@ -95,16 +95,16 @@ %td .pull-right - if can?(current_user, :read_build, job) && job.artifacts? - = link_to download_namespace_project_build_artifacts_path(job.project.namespace, job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do + = link_to download_namespace_project_job_artifacts_path(job.project.namespace, job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do = icon('download') - if can?(current_user, :update_build, job) - if job.active? - = link_to cancel_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do + = link_to cancel_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do = icon('remove', class: 'cred') - elsif allow_retry - if job.playable? && !admin && can?(current_user, :update_build, job) - = link_to play_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do + = link_to play_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do = custom_icon('icon_play') - elsif job.retryable? - = link_to retry_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do + = link_to retry_namespace_project_job_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do = icon('repeat') diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml index 506246f2ee6..e2baaa625ae 100644 --- a/app/views/projects/deployments/_actions.haml +++ b/app/views/projects/deployments/_actions.haml @@ -8,6 +8,7 @@ = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right - actions.each do |action| + - next unless can?(current_user, :update_build, action) %li = link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow' do = custom_icon('icon_play') diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml index c781e423c4d..59844bc00cd 100644 --- a/app/views/projects/diffs/_content.html.haml +++ b/app/views/projects/diffs/_content.html.haml @@ -1,12 +1,12 @@ -.diff-content.diff-wrap-lines - -# Skip all non non-supported blobs - - return unless blob.respond_to?(:text?) +- blob = diff_file.blob + +.diff-content - if diff_file.too_large? .nothing-here-block This diff could not be displayed because it is too large. - - elsif blob.too_large? + - elsif blob.truncated? .nothing-here-block The file could not be displayed because it is too large. - elsif blob.readable_text? - - if !project.repository.diffable?(blob) + - if !diff_file.repository.diffable?(blob) .nothing-here-block This diff was suppressed by a .gitattributes entry. - elsif diff_file.collapsed? - url = url_for(params.merge(action: :diff_for_path, old_path: diff_file.old_path, new_path: diff_file.new_path, file_identifier: diff_file.file_identifier)) @@ -15,20 +15,13 @@ %a.click-to-expand Click to expand it. - elsif diff_file.diff_lines.length > 0 - - total_lines = 0 - - if blob.lines.any? - - total_lines = blob.lines.last.chomp == '' ? blob.lines.size - 1 : blob.lines.size - - if diff_view == :parallel - = render "projects/diffs/parallel_view", diff_file: diff_file, total_lines: total_lines - - else - = render "projects/diffs/text_file", diff_file: diff_file, total_lines: total_lines + = render "projects/diffs/viewers/text", diff_file: diff_file - else - if diff_file.mode_changed? .nothing-here-block File mode changed - - elsif diff_file.renamed_file + - elsif diff_file.renamed_file? .nothing-here-block File moved - elsif blob.image? - - old_blob = diff_file.old_blob(diff_file.old_content_commit || @base_commit) - = render "projects/diffs/image", diff_file: diff_file, old_file: old_blob, file: blob + = render "projects/diffs/viewers/image", diff_file: diff_file - else .nothing-here-block No preview for this file type diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 71a1b9e6c05..d538c4c86c8 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -5,8 +5,8 @@ .content-block.oneline-block.files-changed .inline-parallel-buttons - - if !expand_all_diffs? && diff_files.any? { |diff_file| diff_file.collapsed? } - = link_to 'Expand all', url_for(params.merge(expand_all_diffs: 1, format: nil)), class: 'btn btn-default' + - if !diffs_expanded? && diff_files.any? { |diff_file| diff_file.collapsed? } + = link_to 'Expand all', url_for(params.merge(expanded: 1, format: nil)), class: 'btn btn-default' - if show_whitespace_toggle - if current_controller?(:commit) = commit_diff_whitespace_link(diffs.project, @commit, class: 'hidden-xs') @@ -23,12 +23,4 @@ = render 'projects/diffs/warning', diff_files: diffs .files{ data: { can_create_note: can_create_note } } - - diff_files.each_with_index do |diff_file| - - diff_commit = commit_for_diff(diff_file) - - blob = diff_file.blob(diff_commit) - - next unless blob - - blob.load_all_data!(diffs.project.repository) unless blob.too_large? - - file_hash = hexdigest(diff_file.file_path) - - = render 'projects/diffs/file', file_hash: file_hash, project: diffs.project, - diff_file: diff_file, diff_commit: diff_commit, blob: blob, environment: environment + = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment } diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index f22b385fc0f..b5aea217384 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -1,10 +1,12 @@ - environment = local_assigns.fetch(:environment, nil) -.diff-file.file-holder{ id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_commit.id) } +- file_hash = hexdigest(diff_file.file_path) +.diff-file.file-holder{ id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_file.content_sha) } .js-file-title.file-title-flex-parent .file-header-content - = render "projects/diffs/file_header", diff_file: diff_file, blob: blob, diff_commit: diff_commit, project: project, url: "##{file_hash}" + = render "projects/diffs/file_header", diff_file: diff_file, url: "##{file_hash}" - unless diff_file.submodule? + - blob = diff_file.blob .file-actions.hidden-xs - if blob.readable_text? = link_to '#', class: 'js-toggle-diff-comments btn active has-tooltip', title: "Toggle comments for this file", disabled: @diff_notes_disabled do @@ -15,9 +17,9 @@ = edit_blob_link(@merge_request.source_project, @merge_request.source_branch, diff_file.new_path, blob: blob, link_opts: link_opts) - = view_file_button(diff_commit.id, diff_file.new_path, project) - = view_on_environment_button(diff_commit.id, diff_file.new_path, environment) if environment + = view_file_button(diff_file.content_sha, diff_file.file_path, project) + = view_on_environment_button(diff_file.content_sha, diff_file.file_path, environment) if environment = render 'projects/fork_suggestion' - = render 'projects/diffs/content', diff_file: diff_file, diff_commit: diff_commit, blob: blob, project: project + = render 'projects/diffs/content', diff_file: diff_file diff --git a/app/views/projects/diffs/_file_header.html.haml b/app/views/projects/diffs/_file_header.html.haml index 4e4fdb73ae3..73c316472e3 100644 --- a/app/views/projects/diffs/_file_header.html.haml +++ b/app/views/projects/diffs/_file_header.html.haml @@ -3,19 +3,20 @@ - if show_toggle %i.fa.diff-toggle-caret.fa-fw -- if defined?(blob) && blob && diff_file.submodule? +- if diff_file.submodule? + - blob = diff_file.blob %span = icon('archive fw') %strong.file-title-name - = submodule_link(blob, diff_commit.id, project.repository) + = submodule_link(blob, diff_file.content_sha, diff_file.repository) = copy_file_path_button(blob.path) - else = conditional_link_to url.present?, url do = blob_icon diff_file.b_mode, diff_file.file_path - - if diff_file.renamed_file + - if diff_file.renamed_file? - old_path, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) %strong.file-title-name.has-tooltip{ data: { title: diff_file.old_path, container: 'body' } } = old_path @@ -23,12 +24,13 @@ %strong.file-title-name.has-tooltip{ data: { title: diff_file.new_path, container: 'body' } } = new_path - else - %strong.file-title-name.has-tooltip{ data: { title: diff_file.new_path, container: 'body' } } - = diff_file.new_path - - if diff_file.deleted_file + %strong.file-title-name.has-tooltip{ data: { title: diff_file.file_path, container: 'body' } } + = diff_file.file_path + + - if diff_file.deleted_file? deleted - = copy_file_path_button(diff_file.new_path) + = copy_file_path_button(diff_file.file_path) - if diff_file.mode_changed? %small diff --git a/app/views/projects/diffs/_image.html.haml b/app/views/projects/diffs/_image.html.haml deleted file mode 100644 index ca10921c5e2..00000000000 --- a/app/views/projects/diffs/_image.html.haml +++ /dev/null @@ -1,69 +0,0 @@ -- diff = diff_file.diff -- file_raw_path = namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.new_ref, diff.new_path)) -// diff_refs will be nil for orphaned commits (e.g. first commit in repo) -- if diff_file.old_ref - - old_file_raw_path = namespace_project_raw_path(@project.namespace, @project, tree_join(diff_file.old_ref, diff.old_path)) - -- if diff.renamed_file || diff.new_file || diff.deleted_file - .image - %span.wrap - .frame{ class: image_diff_class(diff) } - %img{ src: diff.deleted_file ? old_file_raw_path : file_raw_path, alt: diff.new_path } - %p.image-info= number_to_human_size(file.size) -- else - .image - .two-up.view - %span.wrap - .frame.deleted - %a{ href: namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.old_ref, diff.old_path)) } - %img{ src: old_file_raw_path, alt: diff.old_path } - %p.image-info.hide - %span.meta-filesize= number_to_human_size(old_file.size) - | - %b W: - %span.meta-width - | - %b H: - %span.meta-height - %span.wrap - .frame.added - %a{ href: namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.new_ref, diff.new_path)) } - %img{ src: file_raw_path, alt: diff.new_path } - %p.image-info.hide - %span.meta-filesize= number_to_human_size(file.size) - | - %b W: - %span.meta-width - | - %b H: - %span.meta-height - - .swipe.view.hide - .swipe-frame - .frame.deleted - %img{ src: old_file_raw_path, alt: diff.old_path } - .swipe-wrap - .frame.added - %img{ src: file_raw_path, alt: diff.new_path } - %span.swipe-bar - %span.top-handle - %span.bottom-handle - - .onion-skin.view.hide - .onion-skin-frame - .frame.deleted - %img{ src: old_file_raw_path, alt: diff.old_path } - .frame.added - %img{ src: file_raw_path, alt: diff.new_path } - .controls - .transparent - .drag-track - .dragger{ :style => "left: 0px;" } - .opaque - - - .view-modes.hide - %ul.view-modes-menu - %li.two-up{ data: { mode: 'two-up' } } 2-up - %li.swipe{ data: { mode: 'swipe' } } Swipe - %li.onion-skin{ data: { mode: 'onion-skin' } } Onion skin diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index 7439b8a66f7..43708d22a0c 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -3,7 +3,7 @@ - discussions = local_assigns.fetch(:discussions, nil) - type = line.type - line_code = diff_file.line_code(line) -- if discussions && !line.meta? +- if discussions && line.discussable? - line_discussions = discussions[line_code] %tr.line_holder{ class: type, id: (line_code unless plain) } - case type diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml index 45c95f7ab6a..8e5f4d2573d 100644 --- a/app/views/projects/diffs/_parallel_view.html.haml +++ b/app/views/projects/diffs/_parallel_view.html.haml @@ -49,7 +49,7 @@ - if discussions_left || discussions_right = render "discussions/parallel_diff_discussion", discussions_left: discussions_left, discussions_right: discussions_right - - if !diff_file.new_file && !diff_file.deleted_file && diff_file.diff_lines.any? + - if !diff_file.new_file? && !diff_file.deleted_file? && diff_file.diff_lines.any? - last_line = diff_file.diff_lines.last - if last_line.new_pos < total_lines %tr.line_holder.parallel diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml index fd4f3c8d3cc..e69c7f20d49 100644 --- a/app/views/projects/diffs/_stats.html.haml +++ b/app/views/projects/diffs/_stats.html.haml @@ -12,19 +12,19 @@ - diff_files.each do |diff_file| - file_hash = hexdigest(diff_file.file_path) %li - - if diff_file.deleted_file + - if diff_file.deleted_file? %span.deleted-file %a{ href: "##{file_hash}" } %i.fa.fa-minus = diff_file.old_path - - elsif diff_file.renamed_file + - elsif diff_file.renamed_file? %span.renamed-file %a{ href: "##{file_hash}" } %i.fa.fa-minus = diff_file.old_path → = diff_file.new_path - - elsif diff_file.new_file + - elsif diff_file.new_file? %span.new-file %a{ href: "##{file_hash}" } %i.fa.fa-plus diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml index 5f3968b6709..e8a5e63e59e 100644 --- a/app/views/projects/diffs/_text_file.html.haml +++ b/app/views/projects/diffs/_text_file.html.haml @@ -3,13 +3,13 @@ .suppressed-container %a.show-suppressed-diff.js-show-suppressed-diff Changes suppressed. Click to show. -%table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' } +%table.text-file.diff-wrap-lines.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' } = render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, discussions: @grouped_diff_discussions } - - if !diff_file.new_file && !diff_file.deleted_file && diff_file.highlighted_diff_lines.any? + - if !diff_file.new_file? && !diff_file.deleted_file? && diff_file.highlighted_diff_lines.any? - last_line = diff_file.highlighted_diff_lines.last - if last_line.new_pos < total_lines %tr.line_holder diff --git a/app/views/projects/diffs/viewers/_image.html.haml b/app/views/projects/diffs/viewers/_image.html.haml new file mode 100644 index 00000000000..ea75373581e --- /dev/null +++ b/app/views/projects/diffs/viewers/_image.html.haml @@ -0,0 +1,68 @@ +- blob = diff_file.blob +- old_blob = diff_file.old_blob +- blob_raw_path = diff_file_blob_raw_path(diff_file) +- old_blob_raw_path = diff_file_old_blob_raw_path(diff_file) + +- if diff_file.new_file? || diff_file.deleted_file? + .image + %span.wrap + .frame{ class: (diff_file.deleted_file? ? 'deleted' : 'added') } + %img{ src: blob_raw_path, alt: diff_file.file_path } + %p.image-info= number_to_human_size(blob.size) +- else + .image + .two-up.view + %span.wrap + .frame.deleted + %a{ href: namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.old_content_sha, diff_file.old_path)) } + %img{ src: old_blob_raw_path, alt: diff_file.old_path } + %p.image-info.hide + %span.meta-filesize= number_to_human_size(old_blob.size) + | + %b W: + %span.meta-width + | + %b H: + %span.meta-height + %span.wrap + .frame.added + %a{ href: namespace_project_blob_path(@project.namespace, @project, tree_join(diff_file.content_sha, diff_file.new_path)) } + %img{ src: blob_raw_path, alt: diff_file.new_path } + %p.image-info.hide + %span.meta-filesize= number_to_human_size(blob.size) + | + %b W: + %span.meta-width + | + %b H: + %span.meta-height + + .swipe.view.hide + .swipe-frame + .frame.deleted + %img{ src: old_blob_raw_path, alt: diff_file.old_path } + .swipe-wrap + .frame.added + %img{ src: blob_raw_path, alt: diff_file.new_path } + %span.swipe-bar + %span.top-handle + %span.bottom-handle + + .onion-skin.view.hide + .onion-skin-frame + .frame.deleted + %img{ src: old_blob_raw_path, alt: diff_file.old_path } + .frame.added + %img{ src: blob_raw_path, alt: diff_file.new_path } + .controls + .transparent + .drag-track + .dragger{ :style => "left: 0px;" } + .opaque + + + .view-modes.hide + %ul.view-modes-menu + %li.two-up{ data: { mode: 'two-up' } } 2-up + %li.swipe{ data: { mode: 'swipe' } } Swipe + %li.onion-skin{ data: { mode: 'onion-skin' } } Onion skin diff --git a/app/views/projects/diffs/viewers/_text.html.haml b/app/views/projects/diffs/viewers/_text.html.haml new file mode 100644 index 00000000000..e4b89671724 --- /dev/null +++ b/app/views/projects/diffs/viewers/_text.html.haml @@ -0,0 +1,8 @@ +- blob = diff_file.blob +- blob.load_all_data!(diff_file.repository) +- total_lines = blob.lines.size +- total_lines -= 1 if total_lines > 0 && blob.lines.last.blank? +- if diff_view == :parallel + = render "projects/diffs/parallel_view", diff_file: diff_file, total_lines: total_lines +- else + = render "projects/diffs/text_file", diff_file: diff_file, total_lines: total_lines diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 7315e671056..9e221240cf2 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -13,7 +13,7 @@ = render 'projects/environments/metrics_button', environment: @environment - if can?(current_user, :update_environment, @environment) = link_to 'Edit', edit_namespace_project_environment_path(@project.namespace, @project, @environment), class: 'btn' - - if can?(current_user, :create_deployment, @environment) && @environment.can_stop? + - if can?(current_user, :stop_environment, @environment) = link_to 'Stop', stop_namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure you want to stop this environment?' }, class: 'btn btn-danger', method: :post .environments-container diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml new file mode 100644 index 00000000000..6962b223451 --- /dev/null +++ b/app/views/projects/hook_logs/_index.html.haml @@ -0,0 +1,37 @@ +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + Recent Deliveries + %p When an event in GitLab triggers a webhook, you can use the request details to figure out if something went wrong. + .col-lg-9 + - if hook_logs.any? + %table.table + %thead + %tr + %th Status + %th Trigger + %th URL + %th Elapsed time + %th Request time + %th + - hook_logs.each do |hook_log| + %tr + %td + = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log } + %td.hidden-xs + %span.label.label-gray.deploy-project-label + = hook_log.trigger.singularize.titleize + %td + = truncate(hook_log.url, length: 50) + %td.light + #{number_with_precision(hook_log.execution_duration, precision: 2)} ms + %td.light + = time_ago_with_tooltip(hook_log.created_at) + %td + = link_to 'View details', namespace_project_hook_hook_log_path(project.namespace, project, hook, hook_log) + + = paginate hook_logs, theme: 'gitlab' + + - else + .settings-message.text-center + You don't have any webhooks deliveries diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml new file mode 100644 index 00000000000..2eabe92f8eb --- /dev/null +++ b/app/views/projects/hook_logs/show.html.haml @@ -0,0 +1,11 @@ += render 'projects/settings/head' + +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + Request details + .col-lg-9 + + = link_to 'Resend Request', retry_namespace_project_hook_hook_log_path(@project.namespace, @project, @hook, @hook_log), class: "btn btn-default pull-right prepend-left-10" + + = render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log } diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml index 7998713be1f..fd382c1d63f 100644 --- a/app/views/projects/hooks/edit.html.haml +++ b/app/views/projects/hooks/edit.html.haml @@ -1,3 +1,4 @@ +- page_title 'Integrations' = render 'projects/settings/head' .row.prepend-top-default @@ -10,5 +11,12 @@ .col-lg-9.append-bottom-default = form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: namespace_project_hook_path do |f| = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } + = f.submit 'Save changes', class: 'btn btn-create' + = link_to 'Test hook', test_namespace_project_hook_path(@project.namespace, @project, @hook), class: 'btn btn-default' + = link_to 'Remove', namespace_project_hook_path(@project.namespace, @project, @hook), method: :delete, class: 'btn btn-remove pull-right', data: { confirm: 'Are you sure?' } + +%hr + += render partial: 'projects/hook_logs/index', locals: { hook: @hook, hook_logs: @hook_logs, project: @project } diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 67403c36d7f..d909b0bfbbd 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -31,7 +31,7 @@ %ul - if can_update_issue %li - = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue) + = link_to 'Edit', edit_namespace_project_issue_path(@project.namespace, @project, @issue), class: 'issuable-edit' %li = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' %li @@ -55,17 +55,15 @@ .issue-details.issuable-details .detail-page-description.content-block - #js-issuable-app{ "data" => { "endpoint" => realtime_changes_namespace_project_issue_path(@project.namespace, @project, @issue), - "can-update" => can?(current_user, :update_issue, @issue).to_s, - "issuable-ref" => @issue.to_reference, - } } + %script#js-issuable-app-initial-data{ type: "application/json" }= issuable_initial_data(@issue) + #js-issuable-app %h2.title= markdown_field(@issue, :title) - if @issue.description.present? .description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' } .wiki= markdown_field(@issue, :description) %textarea.hidden.js-task-list-field= @issue.description - = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') + = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') #merge-requests{ data: { url: referenced_merge_requests_namespace_project_issue_url(@project.namespace, @project, @issue) } } // This element is filled in using JavaScript. diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/jobs/_header.html.haml index d4cdb709b97..ad72ab5b199 100644 --- a/app/views/projects/builds/_header.html.haml +++ b/app/views/projects/jobs/_header.html.haml @@ -6,7 +6,7 @@ = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title %strong Job - = link_to "##{@build.id}", namespace_project_build_path(@project.namespace, @project, @build), class: 'js-build-id' + = link_to "##{@build.id}", namespace_project_job_path(@project.namespace, @project, @build), class: 'js-build-id' in pipeline %strong = link_to "##{pipeline.id}", pipeline_path(pipeline) @@ -17,7 +17,7 @@ %strong = link_to @build.ref, project_ref_path(@project, @build.ref), class: 'ref-name' - = render "projects/builds/user" if @build.user + = render "projects/jobs/user" if @build.user = time_ago_with_tooltip(@build.created_at) @@ -26,6 +26,6 @@ - if can?(current_user, :create_issue, @project) && @build.failed? = link_to "New issue", new_namespace_project_issue_path(@project.namespace, @project, issue: build_failed_issue_options), class: 'btn btn-new btn-inverted' - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post + = link_to "Retry job", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-inverted-secondary', method: :post %button.btn.btn-default.pull-right.visible-xs-block.visible-sm-block.build-gutter-toggle.js-sidebar-build-toggle{ role: "button", type: "button" } = icon('angle-double-left') diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml index 8032d81cd91..f700b5c9455 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/jobs/_sidebar.html.haml @@ -30,21 +30,21 @@ - if @build.artifacts? .btn-group.btn-group-justified{ role: :group } - if @build.has_expiring_artifacts? && can?(current_user, :update_build, @build) - = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do + = link_to keep_namespace_project_job_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do Keep - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do + = link_to download_namespace_project_job_artifacts_path(@project.namespace, @project, @build), rel: 'nofollow', download: '', class: 'btn btn-sm btn-default' do Download - if @build.artifacts_metadata? - = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + = link_to browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do Browse .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) } .title Job details - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry job", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post + = link_to "Retry job", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post - if @build.merge_request %p.build-detail-row %span.build-light-text Merge Request: @@ -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_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' - if @build.active? - = link_to "Cancel", cancel_namespace_project_build_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_build_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 + = link_to "Cancel", cancel_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post - if @build.trigger_request .build-widget @@ -126,7 +119,7 @@ - HasStatus::ORDERED_STATUSES.each do |build_status| - builds.select{|build| build.status == build_status}.each do |build| .build-job{ class: sidebar_build_class(build, @build), data: { stage: build.stage } } - = link_to namespace_project_build_path(@project.namespace, @project, build) do + = link_to namespace_project_job_path(@project.namespace, @project, build) do = icon('arrow-right') %span{ class: "ci-status-icon-#{build.status}" } = ci_icon_for_status(build.status) @@ -137,6 +130,3 @@ = build.id - if build.retried? %i.fa.fa-refresh.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' } - -:javascript - new Sidebar(); diff --git a/app/views/projects/builds/_table.html.haml b/app/views/projects/jobs/_table.html.haml index 82806f022ee..82806f022ee 100644 --- a/app/views/projects/builds/_table.html.haml +++ b/app/views/projects/jobs/_table.html.haml diff --git a/app/views/projects/builds/_user.html.haml b/app/views/projects/jobs/_user.html.haml index 83f299da651..83f299da651 100644 --- a/app/views/projects/builds/_user.html.haml +++ b/app/views/projects/jobs/_user.html.haml diff --git a/app/views/projects/builds/index.html.haml b/app/views/projects/jobs/index.html.haml index a8c8afe2695..a33e3978ee1 100644 --- a/app/views/projects/builds/index.html.haml +++ b/app/views/projects/jobs/index.html.haml @@ -4,13 +4,13 @@ %div{ class: container_class } .top-area - - build_path_proc = ->(scope) { project_builds_path(@project, scope: scope) } + - build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) } = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope .nav-controls - if can?(current_user, :update_build, @project) - if @all_builds.running_or_pending.any? - = link_to 'Cancel running', cancel_all_namespace_project_builds_path(@project.namespace, @project), + = link_to 'Cancel running', cancel_all_namespace_project_jobs_path(@project.namespace, @project), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post - unless @repository.gitlab_ci_yml diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/jobs/show.html.haml index 7cb2ec83cc7..0d10dfcef70 100644 --- a/app/views/projects/builds/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_build_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 502220232a1..2cb3045f83e 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 082a6bcbb2a..7bde839e26f 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/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index db9d77dba16..a33da149c62 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -11,7 +11,7 @@ - if project_nav_tab? :builds = nav_link(controller: [:builds, :artifacts]) do - = link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do + = link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do %span Jobs diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 8607da8fcdd..673c3370b62 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -1,18 +1,4 @@ -.page-content-header - .header-main-content - = render 'ci/status/badge', status: @pipeline.detailed_status(current_user), title: @pipeline.status_title - %strong Pipeline ##{@pipeline.id} - triggered #{time_ago_with_tooltip(@pipeline.created_at)} - - if @pipeline.user - by - = user_avatar(user: @pipeline.user, size: 24) - = user_link(@pipeline.user) - .header-action-buttons - - if can?(current_user, :update_pipeline, @pipeline.project) - - if @pipeline.retryable? - = link_to "Retry", retry_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'js-retry-button btn btn-inverted-secondary', method: :post - - if @pipeline.cancelable? - = link_to "Cancel running", cancel_namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post +#js-pipeline-header-vue.pipeline-header-container - if @commit .commit-box diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 075ddc0025c..01cf2cc80e5 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? @@ -55,5 +51,5 @@ %span.stage = build.stage.titleize %span.build-name - = link_to build.name, pipeline_build_url(pipeline, build) + = link_to build.name, pipeline_job_url(pipeline, build) %pre.build-log= build_summary(build, skip: index >= 10) diff --git a/app/views/projects/pipelines/charts/_overall.haml b/app/views/projects/pipelines/charts/_overall.haml index edc4f7b079f..0b7e3d22dd7 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 49c1d886423..b39453a50fb 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/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml index 1b1910b5c0f..3b17daeb6da 100644 --- a/app/views/projects/pipelines_settings/_show.html.haml +++ b/app/views/projects/pipelines_settings/_show.html.haml @@ -42,7 +42,7 @@ = f.label :build_timeout_in_minutes, 'Timeout', class: 'label-light' = f.number_field :build_timeout_in_minutes, class: 'form-control', min: '0' %p.help-block - Per job in minutes. If a job passes this threshold, it will be marked as failed. + Per job in minutes. If a job passes this threshold, it will be marked as failed = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank' %hr diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index be128e92fa7..5661af01302 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -1,26 +1,60 @@ - page_title "Container Registry" -%hr - -%ul.content-list - %li.light.prepend-top-default +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + = page_title %p - A 'container image' is a snapshot of a container. - You can host your container images with GitLab. - %br - To start using container images hosted on GitLab you first need to login: - %pre - %code + With the Docker Container Registry integrated into GitLab, every project + can have its own space to store its Docker images. + %p.append-bottom-0 + = succeed '.' do + Learn more about + = link_to 'Container Registry', help_page_path('user/project/container_registry'), target: '_blank' + + .col-lg-9 + .panel.panel-default + .panel-heading + %h4.panel-title + How to use the Container Registry + .panel-body + %p + First log in to GitLab’s Container Registry using your GitLab username + and password. If you have + = link_to '2FA enabled', help_page_path('user/profile/account/two_factor_authentication'), target: '_blank' + you need to use a + = succeed ':' do + = link_to 'personal access token', help_page_path('user/profile/account/two_factor_authentication', anchor: 'personal-access-tokens'), target: '_blank' + %pre docker login #{Gitlab.config.registry.host_port} - %br - Then you are free to create and upload a container image with build and push commands: - %pre - docker build -t #{escape_once(@project.container_registry_url)}/image . %br - docker push #{escape_once(@project.container_registry_url)}/image + %p + Once you log in, you’re free to create and upload a container image + using the common + %code build + and + %code push + commands: + %pre + :plain + docker build -t #{escape_once(@project.container_registry_url)} . + docker push #{escape_once(@project.container_registry_url)} - - if @images.blank? - .nothing-here-block No container image repositories in Container Registry for this project. + %hr + %h5.prepend-top-default + Use different image names + %p.light + GitLab supports up to 3 levels of image names. The following + examples of images are valid for your project: + %pre + :plain + #{escape_once(@project.container_registry_url)}:tag + #{escape_once(@project.container_registry_url)}/optional-image-name:tag + #{escape_once(@project.container_registry_url)}/optional-name/optional-image-name:tag - - else - = render partial: 'image', collection: @images + - if @images.blank? + %p.settings-message.text-center.append-bottom-default + No container images stored for this project. Add one by following the + instructions above. + - else + = render partial: 'image', collection: @images diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index f1a80f1d5e1..9167789a69d 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -1,3 +1,6 @@ +- content_for :page_specific_javascripts do + = webpack_bundle_tag('integrations') + .row.prepend-top-default.append-bottom-default .col-lg-3 %h4.prepend-top-0 @@ -6,15 +9,17 @@ %p= @service.description .col-lg-9 - = form_for(@service, as: :service, url: namespace_project_service_path(@project.namespace, @project, @service.to_param), method: :put, html: { class: 'form-horizontal' }) do |form| + = form_for(@service, as: :service, url: namespace_project_service_path(@project.namespace, @project, @service.to_param), method: :put, html: { class: 'gl-show-field-errors form-horizontal js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_namespace_project_service_path } }) do |form| = render 'shared/service_settings', form: form, subject: @service .footer-block.row-content-block - = form.submit 'Save changes', class: 'btn btn-save' + %button.btn.btn-save{ type: 'submit' } + = icon('spinner spin', class: 'hidden js-btn-spinner') + %span.js-btn-label + Save changes - if @service.valid? && @service.activated? - unless @service.can_test? - disabled_class = 'disabled' - disabled_title = @service.disabled_title - = link_to 'Test settings', test_namespace_project_service_path(@project.namespace, @project, @service), class: "btn #{disabled_class}", title: disabled_title - = link_to "Cancel", namespace_project_settings_integrations_path(@project.namespace, @project), class: "btn btn-cancel" + = link_to 'Cancel', namespace_project_settings_integrations_path(@project.namespace, @project), class: 'btn btn-cancel' diff --git a/app/views/projects/settings/_head.html.haml b/app/views/projects/settings/_head.html.haml index faed65d6588..00bd563999f 100644 --- a/app/views/projects/settings/_head.html.haml +++ b/app/views/projects/settings/_head.html.haml @@ -14,7 +14,7 @@ %span Members - if can_edit - = nav_link(controller: [:integrations, :services, :hooks]) do + = nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do = link_to project_settings_integrations_path(@project), title: 'Integrations' do %span Integrations diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index b51955010ce..f7e410e27b8 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/projects/variables/_content.html.haml b/app/views/projects/variables/_content.html.haml index 06477aba103..98f618ca3b8 100644 --- a/app/views/projects/variables/_content.html.haml +++ b/app/views/projects/variables/_content.html.haml @@ -1,7 +1,8 @@ %h4.prepend-top-0 - Secret Variables + Secret variables + = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank' %p - These variables will be set to environment by the runner. + These variables will be set to environment by the runner, and could be protected by exposing only to protected branches or tags. %p So you can use them for passwords, secret keys or whatever you want. %p diff --git a/app/views/projects/variables/_form.html.haml b/app/views/projects/variables/_form.html.haml index 1ae86d258af..0a70a301cb4 100644 --- a/app/views/projects/variables/_form.html.haml +++ b/app/views/projects/variables/_form.html.haml @@ -7,4 +7,13 @@ .form-group = f.label :value, "Value", class: "label-light" = f.text_area :value, class: "form-control", placeholder: "PROJECT_VARIABLE" + .form-group + .checkbox + = f.label :protected do + = f.check_box :protected + %strong Protected + .help-block + This variable will be passed only to pipelines running on protected branches and tags + = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'protected-secret-variables'), target: '_blank' + = f.submit btn_text, class: "btn btn-save" diff --git a/app/views/projects/variables/_table.html.haml b/app/views/projects/variables/_table.html.haml index 0ce597dcf21..59cd3c4b592 100644 --- a/app/views/projects/variables/_table.html.haml +++ b/app/views/projects/variables/_table.html.haml @@ -3,10 +3,12 @@ %colgroup %col %col + %col %col{ width: 100 } %thead %th Key %th Value + %th Protected %th %tbody - @project.variables.order_key_asc.each do |variable| @@ -14,6 +16,7 @@ %tr %td.variable-key= variable.key %td.variable-value{ "data-value" => variable.value }****** + %td.variable-protected= Gitlab::Utils.boolean_to_yes_no(variable.protected) %td.variable-menu = link_to namespace_project_variable_path(@project.namespace, @project, variable), class: "btn btn-transparent btn-variable-edit" do %span.sr-only diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index 059a0d1ac78..314d8e9cb25 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/_field.html.haml b/app/views/shared/_field.html.haml index d74b0043949..795447a9ca6 100644 --- a/app/views/shared/_field.html.haml +++ b/app/views/shared/_field.html.haml @@ -3,6 +3,7 @@ - value = @service.send(name) - type = field[:type] - placeholder = field[:placeholder] +- required = field[:required] - choices = field[:choices] - default_choice = field[:default_choice] - help = field[:help] @@ -14,14 +15,14 @@ = form.label name, title, class: "control-label" .col-sm-10 - if type == 'text' - = form.text_field name, class: "form-control", placeholder: placeholder + = form.text_field name, class: "form-control", placeholder: placeholder, required: required - elsif type == 'textarea' - = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder + = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required - elsif type == 'checkbox' = form.check_box name - elsif type == 'select' = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control" } - elsif type == 'password' - = form.password_field name, autocomplete: "new-password", class: "form-control" + = form.password_field name, autocomplete: "new-password", class: "form-control", required: value.blank? && :required - if help %span.help-block= help diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml index 90ae3f06a98..8d5b5129454 100644 --- a/app/views/shared/_group_form.html.haml +++ b/app/views/shared/_group_form.html.haml @@ -15,7 +15,7 @@ %strong= parent.full_path + '/' = f.text_field :path, placeholder: 'open-source', class: 'form-control', autofocus: local_assigns[:autofocus] || false, required: true, - pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_JS, + pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, title: 'Please choose a group path with no special characters.', "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" - if parent diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml index fbbf6f358c5..9ed844cf5e7 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/_user_callout.html.haml b/app/views/shared/_user_callout.html.haml index 8308baa7829..17ffcba69d8 100644 --- a/app/views/shared/_user_callout.html.haml +++ b/app/views/shared/_user_callout.html.haml @@ -1,4 +1,4 @@ -.user-callout +.user-callout{ data: { uid: 'user_callout_dismissed' } } .bordered-box.landing.content-block %button.btn.btn-default.close.js-close-callout{ type: 'button', 'aria-label' => 'Dismiss customize experience box' } diff --git a/app/views/shared/hook_logs/_content.html.haml b/app/views/shared/hook_logs/_content.html.haml new file mode 100644 index 00000000000..af6a499fadb --- /dev/null +++ b/app/views/shared/hook_logs/_content.html.haml @@ -0,0 +1,44 @@ +%p + %strong Request URL: + POST + = hook_log.url + = render partial: 'shared/hook_logs/status_label', locals: { hook_log: hook_log } + +%p + %strong Trigger: + %td.hidden-xs + %span.label.label-gray.deploy-project-label + = hook_log.trigger.singularize.titleize +%p + %strong Elapsed time: + #{number_with_precision(hook_log.execution_duration, precision: 2)} ms +%p + %strong Request time: + = time_ago_with_tooltip(hook_log.created_at) + +%hr + +- if hook_log.internal_error_message.present? + .bs-callout.bs-callout-danger + = hook_log.internal_error_message + +%h5 Request headers: +%pre + - hook_log.request_headers.each do |k,v| + <strong>#{k}:</strong> #{v} + %br + +%h5 Request body: +%pre + :plain + #{JSON.pretty_generate(hook_log.request_data)} +%h5 Response headers: +%pre + - hook_log.response_headers.each do |k,v| + <strong>#{k}:</strong> #{v} + %br + +%h5 Response body: +%pre + :plain + #{hook_log.response_body} diff --git a/app/views/shared/hook_logs/_status_label.html.haml b/app/views/shared/hook_logs/_status_label.html.haml new file mode 100644 index 00000000000..b4ea8e6f952 --- /dev/null +++ b/app/views/shared/hook_logs/_status_label.html.haml @@ -0,0 +1,3 @@ +- label_status = hook_log.success? ? 'label-success' : 'label-danger' +%span{ class: "label #{label_status}" } + = hook_log.response_status diff --git a/app/views/shared/icons/_convdev_no_data.svg b/app/views/shared/icons/_convdev_no_data.svg new file mode 100644 index 00000000000..ed32b2333e7 --- /dev/null +++ b/app/views/shared/icons/_convdev_no_data.svg @@ -0,0 +1,40 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="360" height="220" viewBox="0 0 360 220"> + <g fill="none" fill-rule="evenodd"> + <path fill="#000" fill-opacity=".02" d="M125 44V24.003C125 18.48 129.483 14 135.005 14h89.99C230.52 14 235 18.477 235 24.003V43h84.992C326.624 43 332 48.372 332 55.002v144.996c0 6.63-5.38 12.002-12.008 12.002h-85.984c-6.632 0-12.008-5.372-12.008-12.002V183h-78v17.002c0 6.626-5.38 11.998-12.008 11.998H46.008C39.376 212 34 206.624 34 200.002V55.998C34 49.372 39.38 44 46.008 44H125z"/> + <g transform="translate(214 36)"> + <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/> + <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/> + <path fill="#F0EDF8" fill-rule="nonzero" d="M57 111c-11.598 0-21-9.402-21-21s9.402-21 21-21 21 9.402 21 21-9.402 21-21 21zm0-4c9.39 0 17-7.61 17-17s-7.61-17-17-17-17 7.61-17 17 7.61 17 17 17z"/> + <path fill="#6B4FBB" d="M58 88v-6.997c0-1.11-.895-2.003-2-2.003-1.112 0-2 .897-2 2.003v8.994c0 1.11.895 2.003 2 2.003.174 0 .343-.022.503-.063.162.04.33.063.506.063h7.98C66.1 92 67 91.105 67 90c0-1.112-.9-2-2.01-2H58z"/> + <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/> + <path fill="#EEE" d="M21 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C21.895 18 21 17.112 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C34.895 18 34 17.112 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C47.895 18 47 17.112 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C60.895 18 60 17.112 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C73.895 18 73 17.112 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C86.895 18 86 17.112 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C99.895 18 99 17.112 99 16z"/> + </g> + <g transform="translate(118 7)"> + <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/> + <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/> + <g fill-rule="nonzero"> + <path fill="#F0EDF8" d="M57 112c-12.15 0-22-9.85-22-22s9.85-22 22-22 22 9.85 22 22-9.85 22-22 22zm0-6c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16z"/> + <path fill="#6B4FBB" d="M41.692 105.8C45.768 109.75 51.21 112 57 112c12.15 0 22-9.85 22-22s-9.85-22-22-22v6c8.837 0 16 7.163 16 16s-7.163 16-16 16c-4.215 0-8.166-1.633-11.133-4.508l-4.175 4.31z"/> + </g> + <path fill="#EEE" d="M8 16c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2H9.998C8.895 18 8 17.112 8 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C21.895 18 21 17.112 21 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C34.895 18 34 17.112 34 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C47.895 18 47 17.112 47 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C60.895 18 60 17.112 60 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C73.895 18 73 17.112 73 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C86.895 18 86 17.112 86 16zm13 0c0-1.105.887-2 1.998-2h4.004c1.103 0 1.998.888 1.998 2 0 1.105-.887 2-1.998 2h-4.004C99.895 18 99 17.112 99 16z"/> + </g> + <g transform="translate(26 36)"> + <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/> + <path fill="#EEE" fill-rule="nonzero" d="M4 12.006v147.988C4 164.42 7.58 168 12.005 168h89.99c4.42 0 8.005-3.586 8.005-8.006V12.006C110 7.58 106.42 4 101.995 4h-89.99C7.585 4 4 7.586 4 12.006zm-4 0C0 5.376 5.377 0 12.005 0h89.99C108.628 0 114 5.37 114 12.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C5.372 172 0 166.63 0 159.994V12.006z"/> + <g transform="translate(21 82)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/> + </g> + <g transform="translate(69 82)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/> + </g> + <g transform="translate(38 42)"> + <rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/> + <rect width="38" height="4" y="12" fill="#FB722E" rx="2"/> + </g> + <path fill="#EEE" d="M4 14h106v4H4z"/> + <path fill="#333" d="M35.724 138h9.696v-2.856h-2.856V122.76h-2.592c-1.08.648-2.136 1.08-3.792 1.392v2.184h2.856v8.808h-3.312V138zm17.736.288c-2.952 0-5.76-2.208-5.76-7.56 0-5.688 2.952-8.256 6.168-8.256 2.016 0 3.48.84 4.44 1.824l-1.848 2.112c-.528-.576-1.488-1.08-2.376-1.08-1.68 0-3.024 1.2-3.144 4.752.792-1.008 2.112-1.608 3.048-1.608 2.616 0 4.536 1.488 4.536 4.704 0 3.168-2.304 5.112-5.064 5.112zm-.072-2.64c1.056 0 1.92-.744 1.92-2.472 0-1.608-.84-2.208-1.992-2.208-.792 0-1.68.432-2.304 1.512.312 2.4 1.32 3.168 2.376 3.168zM63.9 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/> + </g> + </g> +</svg> diff --git a/app/views/shared/icons/_convdev_no_index.svg b/app/views/shared/icons/_convdev_no_index.svg new file mode 100644 index 00000000000..95c00e81d10 --- /dev/null +++ b/app/views/shared/icons/_convdev_no_index.svg @@ -0,0 +1,67 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="360" height="200" viewBox="0 0 360 200"> + <g fill="none" fill-rule="evenodd" transform="translate(3 11)"> + <rect width="110" height="168" x="6" y="8" fill="#000" fill-opacity=".02" rx="10"/> + <g transform="translate(0 2)"> + <rect width="110" height="168" fill="#FFF" rx="10"/> + <path fill="#EEE" fill-rule="nonzero" d="M2 10.006v147.988C2 162.42 5.58 166 10.005 166h89.99c4.42 0 8.005-3.586 8.005-8.006V10.006C108 5.58 104.42 2 99.995 2h-89.99C5.585 2 2 5.586 2 10.006zm-4 0C-2 3.376 3.377-2 10.005-2h89.99C106.628-2 112 3.37 112 10.006v147.988c0 6.63-5.377 12.006-12.005 12.006h-89.99C3.372 170-2 164.63-2 157.994V10.006z"/> + <g transform="translate(19 80)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/> + </g> + <g transform="translate(67 80)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#6B4FBB" rx="2"/> + </g> + <g transform="translate(36 40)"> + <rect width="22" height="4" x="8" fill="#FEE1D3" rx="2"/> + <rect width="38" height="4" y="12" fill="#FB722E" rx="2"/> + </g> + <path fill="#EEE" d="M2 12h106v4H2z"/> + <path fill="#333" d="M38.048 127.792c.792 0 1.68-.432 2.28-1.512-.312-2.4-1.296-3.168-2.376-3.168-1.032 0-1.92.744-1.92 2.472 0 1.608.864 2.208 2.016 2.208zm-.552 8.496c-2.016 0-3.504-.864-4.464-1.824l1.872-2.112c.504.576 1.464 1.08 2.352 1.08 1.704 0 3.024-1.2 3.144-4.752-.792 1.008-2.112 1.608-3.048 1.608-2.592 0-4.536-1.488-4.536-4.704 0-3.168 2.304-5.112 5.064-5.112 2.952 0 5.784 2.208 5.784 7.56 0 5.688-2.976 8.256-6.168 8.256zm13.488 0c-3.048 0-5.304-1.704-5.304-4.176 0-1.848 1.152-2.976 2.592-3.744v-.096c-1.176-.888-2.04-1.992-2.04-3.6 0-2.592 2.04-4.2 4.872-4.2 2.784 0 4.632 1.656 4.632 4.176 0 1.464-.936 2.64-1.992 3.336v.096c1.464.792 2.64 1.968 2.64 3.984 0 2.4-2.16 4.224-5.4 4.224zm.96-9.168c.6-.696.936-1.44.936-2.232 0-1.176-.696-1.968-1.848-1.968-.936 0-1.704.576-1.704 1.752 0 1.248 1.056 1.848 2.616 2.448zm-.888 6.72c1.176 0 2.04-.624 2.04-1.896 0-1.344-1.296-1.848-3.216-2.664-.672.624-1.176 1.488-1.176 2.424 0 1.344 1.08 2.136 2.352 2.136zm10.8-3.84c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/> + </g> + <g transform="translate(122)"> + <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/> + <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/> + <g transform="translate(21 82)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/> + </g> + <g transform="translate(69 82)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/> + </g> + <path fill="#FEE1D3" d="M44 44c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 46 44 45.112 44 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 46 54 45.112 54 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 46 64 45.112 64 44zM34 56c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C34.894 58 34 57.112 34 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 58 44 57.112 44 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 58 54 57.112 54 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 58 64 57.112 64 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C74.894 58 74 57.112 74 56z"/> + <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="21" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="34" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="47" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="60" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="73" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="86" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="99" y="14" fill="#EEE" rx="2"/> + <path fill="#EEE" d="M46.716 138.288c-3.264 0-5.448-2.784-5.448-7.968s2.184-7.848 5.448-7.848c3.264 0 5.448 2.664 5.448 7.848 0 5.184-2.184 7.968-5.448 7.968zm0-2.736c1.2 0 2.112-1.08 2.112-5.232 0-4.176-.912-5.112-2.112-5.112-1.176 0-2.112.936-2.112 5.112 0 4.152.936 5.232 2.112 5.232zM57.564 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/> + </g> + <g transform="translate(243)"> + <rect width="110" height="168" x="2" y="2" fill="#FFF" rx="10"/> + <path fill="#EEE" fill-rule="nonzero" d="M4 12.006c0-2.208.896-4.27 2.457-5.77.796-.766.82-2.032.055-2.828-.766-.796-2.032-.82-2.828-.055C1.347 5.6 0 8.7 0 12.006c0 1.105.895 2 2 2s2-.895 2-2zM14.388 4h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm18 0h8c1.104 0 2-.895 2-2s-.896-2-2-2h-8c-1.105 0-2 .895-2 2s.895 2 2 2zm17.51.227c2.115.514 3.93 1.88 5.022 3.756.556.955 1.78 1.28 2.735.724.954-.556 1.278-1.78.723-2.735-1.636-2.813-4.356-4.86-7.534-5.632-1.073-.26-2.155.397-2.416 1.47-.26 1.074.397 2.156 1.47 2.417zM110 16.78v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm0 18v8c0 1.104.895 2 2 2s2-.896 2-2v-8c0-1.105-.895-2-2-2s-2 .895-2 2zm-.024 17.844c-.17 2.186-1.227 4.18-2.903 5.558-.853.702-.976 1.962-.275 2.815.7.854 1.962.977 2.815.275 2.51-2.062 4.096-5.056 4.35-8.338.086-1.1-.737-2.063-1.838-2.15-1.102-.084-2.064.74-2.15 1.84zM98.826 168h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-18 0h-8c-1.104 0-2 .895-2 2s.896 2 2 2h8c1.105 0 2-.895 2-2s-.895-2-2-2zm-17.334-.4c-2.063-.68-3.77-2.186-4.71-4.143-.477-.996-1.67-1.416-2.667-.938-.996.476-1.416 1.67-.938 2.667 1.41 2.936 3.964 5.19 7.063 6.21 1.05.347 2.18-.223 2.526-1.272.346-1.05-.224-2.18-1.274-2.526zM4 154.434v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2zm0-18v-8c0-1.104-.895-2-2-2s-2 .896-2 2v8c0 1.105.895 2 2 2s2-.895 2-2z"/> + <path fill="#FEE1D3" d="M44 44c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 46 44 45.112 44 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 46 54 45.112 54 44zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 46 64 45.112 64 44zM34 56c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C34.894 58 34 57.112 34 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C44.894 58 44 57.112 44 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C54.894 58 54 57.112 54 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C64.894 58 64 57.112 64 56zm10 0c0-1.105.898-2 1.998-2h2.004c1.104 0 1.998.888 1.998 2 0 1.105-.898 2-1.998 2h-2.004C74.894 58 74 57.112 74 56z"/> + <g transform="translate(21 82)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/> + </g> + <g transform="translate(69 82)"> + <rect width="24" height="4" y="10" fill="#F0EDF8" rx="2"/> + <rect width="14" height="4" x="5" fill="#C3B8E3" rx="2"/> + </g> + <rect width="8" height="4" x="8" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="21" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="34" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="47" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="60" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="73" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="86" y="14" fill="#EEE" rx="2"/> + <rect width="8" height="4" x="99" y="14" fill="#EEE" rx="2"/> + <path fill="#EEE" d="M46.716 138.288c-3.264 0-5.448-2.784-5.448-7.968s2.184-7.848 5.448-7.848c3.264 0 5.448 2.664 5.448 7.848 0 5.184-2.184 7.968-5.448 7.968zm0-2.736c1.2 0 2.112-1.08 2.112-5.232 0-4.176-.912-5.112-2.112-5.112-1.176 0-2.112.936-2.112 5.112 0 4.152.936 5.232 2.112 5.232zM57.564 132c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024zm.528 8.256l8.448-16.224h2.04l-8.448 16.224h-2.04zm11.016 0c-2.256 0-3.888-1.848-3.888-4.992 0-3.12 1.632-4.944 3.888-4.944 2.256 0 3.912 1.824 3.912 4.944 0 3.144-1.656 4.992-3.912 4.992zm0-1.968c.792 0 1.44-.792 1.44-3.024s-.648-2.976-1.44-2.976c-.792 0-1.44.744-1.44 2.976s.648 3.024 1.44 3.024z"/> + </g> + </g> +</svg> diff --git a/app/views/shared/icons/_convdev_overview.svg b/app/views/shared/icons/_convdev_overview.svg new file mode 100644 index 00000000000..2f31113bad7 --- /dev/null +++ b/app/views/shared/icons/_convdev_overview.svg @@ -0,0 +1,64 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="208" height="127" viewBox="0 0 208 127" xmlns:xlink="http://www.w3.org/1999/xlink"> + <defs> + <rect id="a" width="58" height="98" y="17" rx="6"/> + <rect id="b" width="58" height="98" x="3.5" y="17" rx="6"/> + <rect id="c" width="58" height="98.394" rx="6"/> + </defs> + <g fill="none" fill-rule="evenodd" transform="translate(1)"> + <path fill="#000" fill-opacity=".06" fill-rule="nonzero" d="M16 11.06c0-1.39.56-2.69 1.534-3.635.398-.386.41-1.025.027-1.426-.382-.402-1.015-.414-1.413-.028C14.785 7.294 14 9.116 14 11.062c0 .556.448 1.007 1 1.007s1-.452 1-1.01zm6.432-5.043h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0h4.8c.552 0 1-.452 1-1.01 0-.556-.448-1.007-1-1.007h-4.8c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zm10.8 0H185c1.21 0 2.354.435 3.254 1.215.42.362 1.05.314 1.41-.108.36-.423.312-1.06-.107-1.422C188.297 4.612 186.694 4 185 4h-.568c-.552 0-1 .45-1 1.008 0 .557.448 1.01 1 1.01zM190 11.932v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008zm0 10.89v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.01v-4.838c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01zm0 10.89v4.84c0 .555.448 1.007 1 1.007s1-.453 1-1.01v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.008zm0 10.888v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008V44.6c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008zm0 10.89v4.84c0 .556.448 1.007 1 1.007s1-.45 1-1.008v-4.84c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01zm0 10.89v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01zm0 10.888v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008zm0 10.89v4.84c0 .556.448 1.007 1 1.007s1-.45 1-1.008v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.007zm0 10.888v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008zm-.24 21.446c-.42 1.304-1.353 2.385-2.572 2.985-.497.244-.703.847-.46 1.348.24.5.84.708 1.336.464 1.707-.84 3.013-2.35 3.598-4.178.17-.53-.12-1.098-.644-1.27-.526-.17-1.09.12-1.26.65zm-8.063 3.49h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.8 0h-4.8c-.552 0-1 .453-1 1.01 0 .557.448 1.008 1 1.008h4.8c.553 0 1-.45 1-1.008 0-.557-.447-1.01-1-1.01zm-10.577-.116c-1.33-.295-2.48-1.13-3.19-2.3-.287-.474-.902-.623-1.373-.333-.472.29-.62.91-.332 1.386.99 1.632 2.6 2.8 4.465 3.215.54.12 1.073-.224 1.192-.768.12-.544-.222-1.082-.762-1.2zM16 105.292v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01zm0-10.89v-4.84c0-.555-.448-1.007-1-1.007s-1 .452-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.007zm0-10.888v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-10.89v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.008v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008zm0-10.89v-4.838c0-.557-.448-1.01-1-1.01s-1 .453-1 1.01v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.01zm0-11.888v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.008v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-9.89v-4.84c0-.556-.448-1.007-1-1.007s-1 .45-1 1.007v4.84c0 .557.448 1.008 1 1.008s1-.45 1-1.008zm0-10.888v-4.84c0-.557-.448-1.008-1-1.008s-1 .45-1 1.008v4.84c0 .556.448 1.008 1 1.008s1-.452 1-1.008zm0-10.89v-4.84c0-.556-.448-1.008-1-1.008s-1 .452-1 1.01v4.838c0 .557.448 1.01 1 1.01s1-.453 1-1.01z"/> + <g transform="translate(74)"> + <rect width="58" height="98" y="20" fill="#000" fill-opacity=".02" rx="6"/> + <use fill="#FFF" xlink:href="#a"/> + <rect width="56" height="96" x="1" y="18" stroke="#EEE" stroke-width="2" rx="6"/> + <g transform="translate(16 45.185)"> + <path fill="#333" d="M.59 33.815h5.655V32.15H4.58v-7.225H3.066c-.63.378-1.246.63-2.212.812v1.274H2.52v5.14H.59v1.665zm10.093.168c-1.778 0-3.094-.994-3.094-2.436 0-1.078.67-1.736 1.51-2.184v-.056c-.685-.518-1.19-1.162-1.19-2.1 0-1.512 1.19-2.45 2.843-2.45 1.624 0 2.702.966 2.702 2.436 0 .854-.546 1.54-1.162 1.946v.055c.854.462 1.54 1.148 1.54 2.324 0 1.4-1.26 2.463-3.15 2.463zm.56-5.348c.35-.406.546-.84.546-1.302 0-.686-.407-1.148-1.08-1.148-.545 0-.993.336-.993 1.022 0 .728.616 1.078 1.526 1.428zm-.518 3.92c.686 0 1.19-.364 1.19-1.106 0-.785-.756-1.08-1.876-1.555-.393.364-.687.868-.687 1.414 0 .783.63 1.245 1.372 1.245zm6.3-2.24c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.063 2.282 2.883 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.462.84-1.764s-.378-1.736-.84-1.736c-.462 0-.84.434-.84 1.736s.378 1.764.84 1.764zm.308 4.816l4.928-9.464h1.19l-4.927 9.463h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.883 2.27-2.883 1.315 0 2.28 1.064 2.28 2.884 0 1.835-.965 2.913-2.28 2.913zm0-1.148c.46 0 .84-.462.84-1.764 0-1.3-.38-1.735-.84-1.735-.463 0-.84.434-.84 1.736 0 1.303.377 1.765.84 1.765z"/> + <rect width="13" height="2" x="6" y=".815" fill="#FB722E" rx="1"/> + <path fill="#F0EDF8" d="M3 47.815c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1z"/> + <rect width="20" height="2" x="3" y="6.815" fill="#FEE1D3" rx="1"/> + </g> + <g transform="translate(10.81)"> + <circle cx="18.19" cy="18" r="18" fill="#FFF"/> + <path fill="#F0EDF8" fill-rule="nonzero" d="M18.19 34c8.837 0 16-7.163 16-16s-7.163-16-16-16-16 7.163-16 16 7.163 16 16 16zm0 2c-9.94 0-18-8.06-18-18s8.06-18 18-18 18 8.06 18 18-8.06 18-18 18z"/> + <g transform="translate(10 11)"> + <path fill="#C3B8E3" fill-rule="nonzero" d="M2.19 13.32L5.397 11h7.783c.566 0 1.01-.444 1.01-1V3c0-.55-.45-1-1.01-1H3.2c-.566 0-1.01.444-1.01 1v10.32zM6.045 13l-3.422 2.476C1.28 16.45.19 15.892.19 14.23V3c0-1.657 1.337-3 3.01-3h9.98c1.663 0 3.01 1.342 3.01 3v7c0 1.657-1.337 3-3.01 3H6.045z"/> + <rect width="4" height="2" x="5.19" y="4" fill="#6B4FBB" rx="1"/> + <rect width="6" height="2" x="5.19" y="7" fill="#6B4FBB" rx="1"/> + </g> + </g> + </g> + <g transform="translate(144.5)"> + <rect width="58" height="98" x=".5" y="20" fill="#000" fill-opacity=".02" rx="6"/> + <use fill="#FFF" xlink:href="#b"/> + <rect width="56" height="96" x="4.5" y="18" stroke="#EEE" stroke-width="2" rx="6"/> + <g transform="translate(19 46.185)"> + <path fill="#333" d="M4.01 33.746c1.793 0 3.305-.938 3.305-2.59 0-1.148-.742-1.876-1.764-2.17v-.056c.953-.406 1.485-1.05 1.485-1.974 0-1.554-1.232-2.436-3.066-2.436-1.093 0-1.99.434-2.8 1.134l1.035 1.26c.56-.49 1.036-.784 1.666-.784.7 0 1.093.364 1.093.98 0 .714-.504 1.19-2.1 1.19v1.456c1.932 0 2.394.49 2.394 1.274 0 .672-.574 1.05-1.442 1.05-.756 0-1.414-.378-1.946-.896l-.953 1.302c.644.756 1.652 1.26 3.094 1.26zm4.51-.168h6.257v-1.736h-1.792c-.42 0-1.036.056-1.484.112 1.443-1.512 2.843-3.108 2.843-4.606 0-1.708-1.19-2.828-2.94-2.828-1.274 0-2.1.476-2.982 1.414l1.12 1.106c.45-.476.94-.91 1.583-.91.77 0 1.26.476 1.26 1.344 0 1.26-1.596 2.786-3.864 4.928v1.176zm9.505-3.5c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.064 2.282 2.884 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.462.84-1.764s-.378-1.736-.84-1.736c-.462 0-.84.434-.84 1.736s.378 1.764.84 1.764zm.308 4.816l4.928-9.464h1.19l-4.927 9.464h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.884 2.27-2.884 1.315 0 2.28 1.064 2.28 2.884 0 1.834-.965 2.912-2.28 2.912zm0-1.148c.46 0 .84-.462.84-1.764s-.38-1.736-.84-1.736c-.463 0-.84.434-.84 1.736s.377 1.764.84 1.764z"/> + <rect width="13" height="2.008" x="7.5" fill="#FB722E" rx="1.004"/> + <path fill="#F0EDF8" d="M3.5 47.19c0-.556.455-1.005 1.006-1.005h17.988c.556 0 1.006.445 1.006 1.004 0 .553-.455 1.003-1.006 1.003H4.506c-.556 0-1.006-.446-1.006-1.004zm0 6.023c0-.555.455-1.004 1.006-1.004h17.988c.556 0 1.006.444 1.006 1.003 0 .554-.455 1.004-1.006 1.004H4.506c-.556 0-1.006-.446-1.006-1.004z"/> + <rect width="20" height="2.008" x="4" y="6.024" fill="#FEE1D3" rx="1.004"/> + </g> + <g transform="translate(14.413)"> + <circle cx="18.087" cy="18" r="18" fill="#FFF"/> + <path fill="#F0EDF8" fill-rule="nonzero" d="M18.087 34c8.836 0 16-7.163 16-16s-7.164-16-16-16c-8.837 0-16 7.163-16 16s7.163 16 16 16zm0 2c-9.942 0-18-8.06-18-18s8.058-18 18-18c9.94 0 18 8.06 18 18s-8.06 18-18 18z"/> + <path fill="#C3B8E3" fill-rule="nonzero" d="M18.087 24c3.313 0 6-2.686 6-6s-2.687-6-6-6c-3.314 0-6 2.686-6 6s2.686 6 6 6zm0 2c-4.42 0-8-3.582-8-8s3.58-8 8-8c4.418 0 8 3.582 8 8s-3.582 8-8 8z"/> + <path fill="#6B4FBB" d="M19.087 17v-2c0-.556-.448-1-1-1-.557 0-1 .448-1 1v3c0 .278.11.528.292.71.18.18.43.29.706.29h3c.557 0 1-.448 1-1 0-.556-.447-1-1-1h-2z"/> + </g> + </g> + <rect width="58" height="98" x="3" y="20" fill="#000" fill-opacity=".02" rx="6"/> + <g transform="translate(0 16.754)"> + <use fill="#FFF" xlink:href="#c"/> + <rect width="56" height="96.394" x="1" y="1" stroke="#EEE" stroke-width="2" rx="6"/> + <g transform="translate(16 29.618)"> + <path fill="#333" d="M3.137 27.84c.462 0 .98-.253 1.33-.883-.182-1.4-.756-1.848-1.386-1.848-.6 0-1.12.433-1.12 1.44 0 .94.505 1.29 1.177 1.29zm-.322 4.955C1.64 32.795.77 32.29.21 31.73l1.093-1.23c.294.335.854.63 1.372.63.994 0 1.764-.7 1.834-2.773-.463.588-1.233.938-1.78.938-1.51 0-2.645-.868-2.645-2.744 0-1.847 1.344-2.98 2.954-2.98 1.72 0 3.373 1.287 3.373 4.41 0 3.317-1.736 4.815-3.598 4.815zm8.12 0c-1.722 0-3.36-1.288-3.36-4.41 0-3.318 1.722-4.816 3.598-4.816 1.176 0 2.03.49 2.59 1.063l-1.078 1.232c-.308-.336-.868-.63-1.386-.63-.98 0-1.765.7-1.835 2.772.462-.588 1.232-.938 1.778-.938 1.526 0 2.646.867 2.646 2.743 0 1.848-1.345 2.982-2.955 2.982zm-.042-1.54c.616 0 1.12-.434 1.12-1.442 0-.938-.49-1.288-1.162-1.288-.46 0-.98.252-1.343.882.182 1.4.77 1.848 1.386 1.848zm6.132-2.128c-1.316 0-2.268-1.078-2.268-2.912 0-1.82.952-2.884 2.268-2.884 1.316 0 2.282 1.065 2.282 2.885 0 1.834-.966 2.912-2.282 2.912zm0-1.148c.462 0 .84-.463.84-1.765 0-1.302-.378-1.736-.84-1.736-.462 0-.84.433-.84 1.735s.378 1.764.84 1.764zm.308 4.815l4.928-9.464h1.19l-4.927 9.465h-1.19zm6.426 0c-1.317 0-2.27-1.078-2.27-2.912 0-1.82.953-2.884 2.27-2.884 1.315 0 2.28 1.063 2.28 2.883 0 1.834-.965 2.912-2.28 2.912zm0-1.148c.46 0 .84-.462.84-1.764s-.38-1.736-.84-1.736c-.463 0-.84.434-.84 1.736s.377 1.764.84 1.764z"/> + <rect width="13" height="2.008" x="6.5" y=".314" fill="#FEE1D3" rx="1.004"/> + <path fill="#F0EDF8" d="M3 46.627c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1zm0 6c0-.552.455-1 .992-1h18.016c.548 0 .992.444.992 1 0 .553-.455 1-.992 1H3.992c-.548 0-.992-.444-.992-1z"/> + <rect width="20" height="2" x="3" y="5.627" fill="#FB722E" rx="1"/> + </g> + </g> + <g transform="translate(10.41)"> + <circle cx="18.589" cy="18" r="18" fill="#FFF"/> + <path fill="#F0EDF8" fill-rule="nonzero" d="M18.59 34c8.836 0 16-7.163 16-16s-7.164-16-16-16c-8.837 0-16 7.163-16 16s7.163 16 16 16zm0 2c-9.942 0-18-8.06-18-18s8.058-18 18-18c9.94 0 18 8.06 18 18s-8.06 18-18 18z"/> + <path fill="#C3B8E3" d="M17.05 19.262h3.367l.248-2.808H17.3l-.25 2.808zm-.177 2.008l-.144 1.627c-.06.662-.646 1.2-1.3 1.2h.25c-.658 0-1.144-.534-1.085-1.2l.144-1.627H13.59c-.554 0-1.003-.446-1.003-1.004 0-.555.455-1.004 1.002-1.004h1.325l.248-2.808h-1.15c-.555 0-1.004-.445-1.004-1.004 0-.554.457-1.004 1.004-1.004h1.33l.106-1.2c.058-.66.644-1.198 1.298-1.198h-.25c.66 0 1.145.533 1.086 1.2l-.106 1.198h3.365l.107-1.2c.058-.66.644-1.198 1.298-1.198h-.25c.66 0 1.145.533 1.086 1.2l-.106 1.198h1.03c.554 0 1.003.446 1.003 1.004 0 .555-.455 1.004-1 1.004H22.8l-.25 2.808h1.037c.554 0 1.002.446 1.002 1.004 0 .554-.456 1.004-1.003 1.004h-1.214l-.144 1.627c-.06.662-.646 1.2-1.3 1.2h.25c-.658 0-1.144-.534-1.085-1.2l.144-1.627h-3.367z"/> + <path fill="#6B4FBB" d="M17.05 19.262l-.177 2.008H14.74l.177-2.008h2.134zm-1.707-4.816h2.135l-.178 2.008h-2.135l.178-2.008zm5.5 0h2.135l-.178 2.008h-2.135l.178-2.008zm1.708 4.816l-.177 2.008H20.24l.177-2.008h2.134z"/> + </g> + </g> +</svg> diff --git a/app/views/shared/icons/_i2p_step_1.svg b/app/views/shared/icons/_i2p_step_1.svg new file mode 100644 index 00000000000..9dedcd5291a --- /dev/null +++ b/app/views/shared/icons/_i2p_step_1.svg @@ -0,0 +1,12 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m45.688 18.854c-4.869-1.989-10.488-1.975-15.29-.001-2.413.979-4.597 2.414-6.493 4.268-1.836 1.8-3.33 3.985-4.346 6.381-1.013 2.38-1.525 4.916-1.525 7.537 0 2.066.33 4.118.983 6.104.469 1.388 1.089 2.706 1.83 3.937-1.275 1.101-2.086 2.725-2.086 4.538 0 3.309 2.691 6 6 6s6-2.691 6-6-2.691-6-6-6c-.779 0-1.522.154-2.205.425-.665-1.105-1.221-2.289-1.642-3.533-.585-1.776-.881-3.618-.881-5.472 0-2.351.459-4.623 1.391-6.814.89-2.096 2.231-4.059 3.88-5.675 1.708-1.669 3.675-2.962 5.85-3.845 4.329-1.778 9.392-1.79 13.78.002 2.17.881 4.137 2.175 5.843 3.84 3.39 3.34 5.257 7.776 5.257 12.493.002 1.86-.294 3.705-.878 5.481-.579 1.75-1.443 3.406-2.569 4.923-2.134 2.866-3.818 4.698-5.174 6.173-2.424 2.643-3.98 4.599-4.383 8.384h-10.815c-.553 0-1 .447-1 1s.447 1 1 1h11.739c.532 0 .971-.416.999-.947.19-3.645 1.345-5.263 3.934-8.09 1.385-1.506 3.107-3.381 5.304-6.331 1.254-1.688 2.218-3.535 2.864-5.489.651-1.98.98-4.04.979-6.109 0-5.256-2.078-10.198-5.856-13.92-1.897-1.851-4.081-3.287-6.49-4.265m-16.927 32.763c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4"/> + <path d="m40 74h-4c-.553 0-1 .447-1 1s.447 1 1 1h4c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m42 70h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m38 10c.553 0 1-.447 1-1v-8c0-.553-.447-1-1-1s-1 .447-1 1v8c0 .553.447 1 1 1"/> + <path d="m20.828 15.828c.256 0 .512-.098.707-.293.391-.391.391-1.023 0-1.414l-5.656-5.656c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l5.656 5.656c.195.195.451.293.707.293"/> + <path d="m10 33h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m60.12 8.465l-5.656 5.656c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l5.656-5.656c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0"/> + <path d="m74 33h-8c-.553 0-1 .447-1 1s.447 1 1 1h8c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m43 66h-10c-.553 0-1 .447-1 1s.447 1 1 1h10c.553 0 1-.447 1-1s-.447-1-1-1"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_10.svg b/app/views/shared/icons/_i2p_step_10.svg new file mode 100644 index 00000000000..dd6fd1457ff --- /dev/null +++ b/app/views/shared/icons/_i2p_step_10.svg @@ -0,0 +1,12 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m5 43c0 .553.447 1 1 1s1-.447 1-1v-4h4c.553 0 1-.447 1-1s-.447-1-1-1h-4v-4c0-.553-.447-1-1-1s-1 .447-1 1v4h-4c-.553 0-1 .447-1 1s.447 1 1 1h4v4"/> + <path d="m75 37h-4v-4c0-.553-.447-1-1-1s-1 .447-1 1v4h-4c-.553 0-1 .447-1 1s.447 1 1 1h4v4c0 .553.447 1 1 1s1-.447 1-1v-4h4c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m21 38c0 .345.178.665.47.848l8 5c.165.103.348.152.529.152.333 0 .659-.166.849-.47.293-.469.15-1.086-.317-1.378l-6.644-4.152 6.644-4.152c.468-.292.61-.909.317-1.378s-.908-.611-1.378-.317l-8 5c-.292.182-.47.502-.47.847"/> + <path d="m55 38c0-.345-.178-.665-.47-.848l-8-5c-.469-.294-1.086-.151-1.378.317-.293.469-.15 1.086.317 1.378l6.644 4.153-6.644 4.152c-.468.292-.61.909-.317 1.378.189.304.516.47.849.47.181 0 .364-.049.529-.152l8-5c.292-.183.47-.503.47-.848"/> + <path d="m41.803 26.05c-.525-.168-1.089.124-1.256.65l-7 22c-.167.525.124 1.088.65 1.256.101.032.202.047.303.047.424 0 .817-.271.953-.697l7-22c.167-.526-.124-1.088-.65-1.256"/> + <path d="m62 7c3.859 0 7 3.141 7 7v11c0 .553.447 1 1 1s1-.447 1-1v-11c0-4.963-4.04-9-9-9h-16.09c-.479-2.833-2.943-5-5.91-5-3.309 0-6 2.691-6 6s2.691 6 6 6c2.967 0 5.431-2.167 5.91-5h16.09m-22 3c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/> + <path d="m6 26c.553 0 1-.447 1-1v-11c0-3.859 3.141-7 7-7h11.09l-3.293 3.293c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l5-5c.391-.391.391-1.023 0-1.414l-5-5c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l3.293 3.293h-11.09c-4.963 0-9 4.04-9 9v11c0 .553.447 1 1 1"/> + <path d="m36 64c-2.967 0-5.431 2.167-5.91 5h-16.09c-3.859 0-7-3.141-7-7v-11c0-.553-.447-1-1-1s-1 .447-1 1v11c0 4.963 4.04 9 9 9h16.09c.478 2.833 2.942 5 5.91 5 3.309 0 6-2.691 6-6s-2.691-6-6-6m0 10c-2.206 0-4-1.794-4-4s1.794-4 4-4 4 1.794 4 4-1.794 4-4 4"/> + <path d="m70 50c-.553 0-1 .447-1 1v11c0 3.859-3.141 7-7 7h-11.09l3.293-3.293c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0l-5 5c-.391.391-.391 1.023 0 1.414l5 5c.195.195.451.293.707.293s.512-.098.707-.293c.391-.391.391-1.023 0-1.414l-3.293-3.293h11.09c4.963 0 9-4.04 9-9v-11c0-.553-.447-1-1-1"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_2.svg b/app/views/shared/icons/_i2p_step_2.svg new file mode 100644 index 00000000000..b8805b90275 --- /dev/null +++ b/app/views/shared/icons/_i2p_step_2.svg @@ -0,0 +1,5 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m42.26 40.44c.558.073 1.045-.329 1.109-.877l2.625-22.444c.033-.283-.057-.567-.246-.781-.189-.214-.462-.336-.747-.336h-14c-.284 0-.555.121-.744.332-.19.212-.281.494-.25.776l3.454 31.575c-1.503 1.285-2.46 3.19-2.46 5.317 0 3.859 3.141 7 7 7s7-3.141 7-7-3.141-7-7-7c-.94 0-1.835.189-2.655.527l-3.23-29.527h11.761l-2.494 21.328c-.065.549.328 1.045.877 1.11m.741 13.562c0 2.757-2.243 5-5 5s-5-2.243-5-5 2.243-5 5-5 5 2.243 5 5"/> + <path d="M73.236,23.749c-0.207-0.513-0.796-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_3.svg b/app/views/shared/icons/_i2p_step_3.svg new file mode 100644 index 00000000000..6c783ed8289 --- /dev/null +++ b/app/views/shared/icons/_i2p_step_3.svg @@ -0,0 +1,12 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m12 8c0-3.309-2.691-6-6-6s-6 2.691-6 6c0 2.967 2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909s2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909s2.167 5.431 5 5.91v8.181c-2.833.478-5 2.942-5 5.909 0 3.309 2.691 6 6 6s6-2.691 6-6c0-2.967-2.167-5.431-5-5.91v-8.18c2.833-.478 5-2.942 5-5.91s-2.167-5.431-5-5.91v-8.18c2.833-.478 5-2.942 5-5.91s-2.167-5.431-5-5.91v-8.18c2.833-.479 5-2.943 5-5.91m-10 0c0-2.206 1.794-4 4-4s4 1.794 4 4-1.794 4-4 4-4-1.794-4-4m8 60c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4m0-20c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4m0-20c0 2.206-1.794 4-4 4s-4-1.794-4-4 1.794-4 4-4 4 1.794 4 4"/> + <path d="m21 6h54c.553 0 1-.447 1-1s-.447-1-1-1h-54c-.553 0-1 .447-1 1s.447 1 1 1"/> + <path d="m21 12h35c.553 0 1-.447 1-1s-.447-1-1-1h-35c-.553 0-1 .447-1 1s.447 1 1 1"/> + <path d="m75 24h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m21 32h34c.553 0 1-.447 1-1s-.447-1-1-1h-34c-.553 0-1 .447-1 1s.447 1 1 1"/> + <path d="m75 44h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m21 52h34c.553 0 1-.447 1-1s-.447-1-1-1h-34c-.553 0-1 .447-1 1s.447 1 1 1"/> + <path d="m75 64h-54c-.553 0-1 .447-1 1s.447 1 1 1h54c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m55 70h-34c-.553 0-1 .447-1 1s.447 1 1 1h34c.553 0 1-.447 1-1s-.447-1-1-1"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_4.svg b/app/views/shared/icons/_i2p_step_4.svg new file mode 100644 index 00000000000..af804c838e0 --- /dev/null +++ b/app/views/shared/icons/_i2p_step_4.svg @@ -0,0 +1,6 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m67.7 10h-6.751c-.507-5.598-5.221-10-10.949-10-6.06 0-11 4.935-11 11s4.935 11 11 11c5.728 0 10.442-4.402 10.949-10h6.751c1.269 0 2.3.987 2.3 2.2v57.6c0 1.213-1.031 2.2-2.3 2.2h-59.4c-1.269 0-2.3-.987-2.3-2.2v-57.6c0-1.213 1.031-2.2 2.3-2.2h15.15c.553 0 1-.447 1-1s-.447-1-1-1h-15.15c-2.371 0-4.3 1.884-4.3 4.2v57.6c0 2.316 1.929 4.2 4.3 4.2h59.4c2.371 0 4.3-1.884 4.3-4.2v-57.6c0-2.316-1.929-4.2-4.3-4.2m-17.7 10c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/> + <path d="m21.293 29.29c-.391.391-.391 1.023 0 1.414l12.975 12.975-12.975 12.974c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l13.682-13.682c.391-.391.391-1.023 0-1.414l-13.682-13.681c-.391-.391-1.023-.391-1.414 0"/> + <path d="m54 59c.553 0 1-.447 1-1s-.447-1-1-1h-12c-.553 0-1 .447-1 1s.447 1 1 1h12"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_5.svg b/app/views/shared/icons/_i2p_step_5.svg new file mode 100644 index 00000000000..e54f707019e --- /dev/null +++ b/app/views/shared/icons/_i2p_step_5.svg @@ -0,0 +1,5 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m48.949 37c-.507-5.598-5.221-10-10.949-10s-10.442 4.402-10.949 10h-13.05c-.553 0-1 .447-1 1s.447 1 1 1h13.05c.507 5.598 5.221 10 10.949 10s10.442-4.402 10.949-10h12.24c.553 0 1-.447 1-1s-.447-1-1-1h-12.24m-10.949 10c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/> + <path d="M73.236,23.749c-0.207-0.513-0.797-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_6.svg b/app/views/shared/icons/_i2p_step_6.svg new file mode 100644 index 00000000000..c57baccc06b --- /dev/null +++ b/app/views/shared/icons/_i2p_step_6.svg @@ -0,0 +1,15 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m14.267 7.32l-4.896 5.277-1.702-1.533c-.409-.369-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/> + <path d="m31 9h44c.553 0 1-.447 1-1s-.447-1-1-1h-44c-.553 0-1 .447-1 1s.447 1 1 1"/> + <path d="m31 15h24c.553 0 1-.447 1-1s-.447-1-1-1h-24c-.553 0-1 .447-1 1s.447 1 1 1"/> + <path d="m11 0c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/> + <path d="m14.267 34.32l-4.896 5.277-1.702-1.533c-.409-.368-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/> + <path d="m75 34h-44c-.553 0-1 .447-1 1s.447 1 1 1h44c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m31 42h24c.553 0 1-.447 1-1s-.447-1-1-1h-24c-.553 0-1 .447-1 1s.447 1 1 1"/> + <path d="m11 27c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/> + <path d="m14.267 61.32l-4.896 5.277-1.702-1.533c-.409-.368-1.043-.338-1.412.074-.369.41-.337 1.042.074 1.412l2.434 2.192c.064.058.139.091.212.13.035.018.065.048.101.062.114.044.235.066.356.066.135 0 .27-.028.396-.082.044-.019.077-.058.118-.084.076-.047.155-.086.219-.154l5.566-6c.375-.404.352-1.037-.054-1.413-.405-.377-1.036-.353-1.412.053"/> + <path d="m11 54c-6.07 0-11 4.935-11 11s4.935 11 11 11 11-4.935 11-11-4.935-11-11-11m0 20c-4.963 0-9-4.04-9-9s4.04-9 9-9 9 4.04 9 9-4.04 9-9 9"/> + <path d="m75 61h-44c-.553 0-1 .447-1 1s.447 1 1 1h44c.553 0 1-.447 1-1s-.447-1-1-1"/> + <path d="m55 67h-24c-.553 0-1 .447-1 1s.447 1 1 1h24c.553 0 1-.447 1-1s-.447-1-1-1"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_7.svg b/app/views/shared/icons/_i2p_step_7.svg new file mode 100644 index 00000000000..e9083de3afa --- /dev/null +++ b/app/views/shared/icons/_i2p_step_7.svg @@ -0,0 +1,7 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="M73.236,23.749c-0.208-0.513-0.798-0.76-1.302-0.552c-0.513,0.207-0.759,0.79-0.552,1.302 C73.119,28.787,74,33.329,74,38c0,19.851-16.149,36-36,36S2,57.851,2,38S18.149,2,38,2c7.6,0,14.83,2.332,20.965,6.74 C58.339,9.702,58,10.825,58,12c0,1.603,0.624,3.109,1.758,4.242C60.891,17.376,62.397,18,64,18c1.603,0,3.109-0.624,4.242-1.758 C69.376,15.109,70,13.603,70,12s-0.624-3.109-1.758-4.242C67.109,6.624,65.603,6,64,6c-1.346,0-2.622,0.445-3.668,1.259 C53.812,2.512,46.104,0,38,0C17.047,0,0,17.047,0,38s17.047,38,38,38s38-17.047,38-38C76,33.07,75.07,28.275,73.236,23.749z M64,8 c1.068,0,2.072,0.416,2.828,1.172S68,10.932,68,12s-0.416,2.072-1.172,2.828c-1.512,1.512-4.145,1.512-5.656,0 C60.416,14.072,60,13.068,60,12s0.416-2.072,1.172-2.828S62.932,8,64,8z"/> + <path d="m27.19 32.17c-.277-.479-.89-.643-1.366-.364l-12.654 7.326c-.309.179-.499.509-.499.865s.19.687.499.865l12.654 7.326c.157.092.33.135.5.135.345 0 .681-.179.866-.499.277-.478.113-1.09-.364-1.366l-11.159-6.461 11.159-6.461c.478-.276.642-.889.364-1.366"/> + <path d="m48.808 47.827c.186.32.521.499.866.499.17 0 .343-.043.5-.135l12.654-7.326c.309-.179.499-.509.499-.865s-.19-.687-.499-.865l-12.654-7.326c-.478-.278-1.09-.114-1.366.364-.277.478-.113 1.09.364 1.366l11.159 6.461-11.159 6.461c-.478.276-.642.889-.364 1.366"/> + <path d="m42.71 23.06l-11.312 33.23c-.179.522.102 1.091.624 1.269.106.037.216.054.322.054.416 0 .805-.262.946-.678l11.312-33.23c.179-.522-.102-1.091-.624-1.269-.523-.181-1.089.101-1.268.624"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_8.svg b/app/views/shared/icons/_i2p_step_8.svg new file mode 100644 index 00000000000..62676b0e12e --- /dev/null +++ b/app/views/shared/icons/_i2p_step_8.svg @@ -0,0 +1,4 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m62.44 54.765l-9.912-11.09c.315-3.881.481-7.241.508-10.271-.029-13.871-3.789-23.05-13.413-32.746-.855-.859-2.411-.828-3.294.059-7.594 7.65-11.139 13.934-12.575 22.3-1.776.062-3.437.776-4.699 2.039-1.321 1.321-2.05 3.079-2.05 4.949s.729 3.628 2.051 4.949c1.321 1.322 3.079 2.051 4.949 2.051s3.628-.729 4.949-2.051c1.322-1.321 2.051-3.079 2.051-4.949 0-1.869-.729-3.627-2.051-4.949-.9-.9-2-1.517-3.205-1.824 1.373-7.859 4.764-13.818 11.999-21.11.128-.13.356-.158.456-.059 9.207 9.274 12.805 18.06 12.832 31.33-.026 3.079-.202 6.527-.536 10.54-.023.273.067.545.25.749l10.166 11.379c.062.076.109.23.093.32l-4.547 17.407c-.004.015-.009.036-.079.106-.062.063-.155.1-.2.106l-3.577.002c-.144-.009-.265-.077-.309-.153l-5.425-10.328c-.173-.329-.515-.535-.886-.535h-15.962c-.371 0-.713.206-.886.535l-5.407 10.303-.069.072c-.07.07-.165.105-.199.105l-3.588.001c-.179-.009-.304-.123-.33-.227l-4.531-17.338c-.029-.146.019-.301.049-.34l10.197-11.415c.367-.412.332-1.044-.08-1.412-.411-.366-1.042-.333-1.412.08l-10.229 11.453c-.448.554-.63 1.312-.474 2.084l4.544 17.396c.253.963 1.146 1.669 2.218 1.719h3.636c.581 0 1.187-.261 1.615-.693.114-.114.286-.286.406-.528l5.144-9.793h14.754l5.16 9.822c.396.697 1.124 1.143 2.01 1.192l3.712-.003c.604-.046 1.137-.285 1.544-.694.313-.316.504-.646.598-1.022l4.557-17.451c.143-.718-.039-1.476-.518-2.066m-33.435-24.765c0 1.335-.521 2.591-1.465 3.535s-2.2 1.465-3.535 1.465-2.591-.521-3.535-1.465-1.465-2.2-1.465-3.535.521-2.591 1.465-3.535 2.2-1.465 3.535-1.465 2.591.521 3.535 1.465 1.465 2.2 1.465 3.535"/> +</svg> diff --git a/app/views/shared/icons/_i2p_step_9.svg b/app/views/shared/icons/_i2p_step_9.svg new file mode 100644 index 00000000000..e4285a14425 --- /dev/null +++ b/app/views/shared/icons/_i2p_step_9.svg @@ -0,0 +1,4 @@ +<?xml version="1.0"?> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 76 76" enable-background="new 0 0 76 76"> + <path d="m68 67c-1.725 0-3.36.541-4.723 1.545-2.298-4.02-6.592-6.545-11.277-6.545-2.734 0-5.359.853-7.555 2.43l-2.286-15.43h1.228l3.829 7.645c.339.598.962.979 1.724 1.022l2.812-.003c.507-.039.974-.25 1.316-.595.264-.266.433-.559.514-.882l3.433-13.145c.12-.611-.037-1.258-.449-1.763l-7.385-8.268c.231-2.875.354-5.376.374-7.641-.023-10.507-2.871-17.462-10.162-24.806-.737-.742-2.072-.715-2.829.044-5.617 5.659-8.309 10.336-9.446 16.463-1.267.186-2.438.764-3.36 1.686-1.134 1.134-1.758 2.64-1.758 4.243s.624 3.109 1.758 4.242c1.133 1.134 2.639 1.758 4.242 1.758s3.109-.624 4.242-1.758c1.134-1.133 1.758-2.639 1.758-4.242s-.624-3.109-1.758-4.242c-.858-.859-1.932-1.424-3.098-1.648 1.095-5.538 3.637-9.855 8.83-15.14 6.874 6.924 9.561 13.485 9.581 23.392-.021 2.316-.151 4.903-.402 7.91-.023.273.067.544.25.749l7.663 8.572-3.391 13.07-2.695.036-4.081-8.15c-.17-.339-.516-.553-.895-.553h-12.01c-.379 0-.725.214-.895.553l-4.04 8.114-2.707.015-3.427-13.07 7.671-8.588c.367-.412.332-1.044-.08-1.412-.411-.366-1.043-.333-1.412.08l-7.7 8.623c-.383.47-.54 1.116-.406 1.787l3.419 13.08c.216.829.98 1.438 1.907 1.48h2.735c.508 0 1.016-.218 1.391-.595.091-.09.242-.241.358-.475l3.804-7.597h1.228l-2.286 15.43c-2.196-1.577-4.821-2.43-7.555-2.43-4.685 0-8.979 2.53-11.277 6.545-1.363-1-2.998-1.545-4.723-1.545-4.411 0-8 3.589-8 8 0 .553.447 1 1 1h74c.553 0 1-.447 1-1 0-4.411-3.589-8-8-8m-36-44c0 1.068-.416 2.072-1.172 2.828-1.512 1.512-4.145 1.512-5.656 0-.756-.756-1.172-1.76-1.172-2.828s.416-2.072 1.172-2.828 1.76-1.172 2.828-1.172 2.072.416 2.828 1.172 1.172 1.76 1.172 2.828m-29.917 51c.478-2.834 2.949-5 5.917-5 1.638 0 3.17.652 4.313 1.836.231.24.562.35.895.29.327-.058.604-.274.739-.579 1.765-3.977 5.711-6.547 10.05-6.547 2.836 0 5.532 1.085 7.593 3.055.271.258.665.345 1.016.224.354-.122.61-.43.665-.8l2.588-17.479h4.275l2.589 17.479c.055.37.312.678.665.8s.745.035 1.016-.224c2.061-1.97 4.757-3.055 7.593-3.055 4.343 0 8.288 2.57 10.05 6.547.135.305.412.521.739.579.329.059.663-.051.895-.29 1.143-1.184 2.675-1.836 4.313-1.836 2.968 0 5.439 2.166 5.917 5h-71.834"/> +</svg> diff --git a/app/views/shared/icons/_scroll_down.svg b/app/views/shared/icons/_scroll_down.svg index acf22ac9314..1d22870ec09 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 262576acf54..00000000000 --- 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 f11288fd59c..70b1e4d9c91 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 4658dbb1bb7..00000000000 --- 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 93c7fa0c7d6..1cf662e29c4 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 d36707dd042..be9f9ee29c4 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -26,8 +26,6 @@ %li.input-token %input.form-control.filtered-search{ id: "filtered-search-#{type.to_s}", placeholder: 'Search or filter results...', data: { 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } } = icon('filter') - %button.clear-search.hidden{ type: 'button' } - = icon('times') #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { action: 'submit' } } @@ -46,30 +44,27 @@ %span.js-filter-tag.dropdown-light-content {{tag}} #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu + - if current_user + %ul{ data: { dropdown: true } } + = render 'shared/issuable/user_dropdown_item', + user: current_user %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.dropdown-user - %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } - .dropdown-user-details - %span - {{name}} - %span.dropdown-light-content - @{{username}} + = render 'shared/issuable/user_dropdown_item', + user: User.new(username: '{{username}}', name: '{{name}}'), + avatar: { lazy: true, url: '{{avatar_url}}' } #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } %button.btn.btn-link No Assignee %li.divider + - if current_user + = render 'shared/issuable/user_dropdown_item', + user: current_user %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.dropdown-user - %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } - .dropdown-user-details - %span - {{name}} - %span.dropdown-light-content - @{{username}} + = render 'shared/issuable/user_dropdown_item', + user: User.new(username: '{{username}}', name: '{{name}}'), + avatar: { lazy: true, url: '{{avatar_url}}' } #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } @@ -98,6 +93,8 @@ %span.dropdown-label-box{ style: 'background: {{color}}' } %span.label-title.js-data-value {{title}} + %button.clear-search.hidden{ type: 'button' } + = icon('times') .filter-dropdown-container - if type == :boards - if can?(current_user, :admin_list, @project) @@ -157,7 +154,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 26567c08eb6..bcfa1dc826e 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/_user_dropdown_item.html.haml b/app/views/shared/issuable/_user_dropdown_item.html.haml new file mode 100644 index 00000000000..a82c01c6dc2 --- /dev/null +++ b/app/views/shared/issuable/_user_dropdown_item.html.haml @@ -0,0 +1,11 @@ +- user = local_assigns.fetch(:user) +- avatar = local_assigns.fetch(:avatar, { }) + +%li.filter-dropdown-item{ class: ('js-current-user' if user == current_user) } + %button.btn.btn-link.dropdown-user{ type: :button } + = user_avatar_without_link(user: user, lazy: avatar[:lazy], url: avatar[:url], size: 30) + .dropdown-user-details + %span + = user.name + %span.dropdown-light-content + = user.to_reference diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index d23f79be2be..271150ed318 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 8119f19291b..77175c839a6 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/_note.html.haml b/app/views/shared/notes/_note.html.haml index a7bf610b9c7..1e34b7c1e76 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -18,7 +18,7 @@ .note-header .note-header-info %a{ href: user_path(note.author) } - %span.hidden-xs + %span.note-header-author-name = sanitize(note.author.name) %span.note-headline-light = note.author.to_reference diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index 785b1b22a49..5902798dfd0 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/app/views/users/show.html.haml b/app/views/users/show.html.haml index 2b70d70e360..f246bd7a586 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -71,7 +71,7 @@ = @user.location - unless @user.organization.blank? .profile-link-holder.middle-dot-divider - = icon('building') + = icon('briefcase') = @user.organization - if @user.bio.present? @@ -100,7 +100,7 @@ Snippets %div{ class: container_class } - - if @user == current_user && !show_user_callout? + - if @user == current_user && show_callout?('user_callout_dismissed') = render 'shared/user_callout' .tab-content #activity.tab-pane diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb index e17add7421f..bf009dfab0f 100644 --- a/app/workers/build_success_worker.rb +++ b/app/workers/build_success_worker.rb @@ -11,15 +11,6 @@ class BuildSuccessWorker private def create_deployment(build) - service = CreateDeploymentService.new( - build.project, build.user, - environment: build.environment, - sha: build.sha, - ref: build.ref, - tag: build.tag, - options: build.options.to_h[:environment], - variables: build.variables) - - service.execute(build) + CreateDeploymentService.new(build).execute end end diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb index 2f02235b0ac..0a55aab63fd 100644 --- a/app/workers/gitlab_usage_ping_worker.rb +++ b/app/workers/gitlab_usage_ping_worker.rb @@ -3,29 +3,17 @@ class GitlabUsagePingWorker include Sidekiq::Worker include CronjobQueue - include HTTParty def perform - return unless current_application_settings.usage_ping_enabled - # Multiple Sidekiq workers could run this. We should only do this at most once a day. return unless try_obtain_lease - begin - HTTParty.post(url, - body: Gitlab::UsageData.to_json(force_refresh: true), - headers: { 'Content-type' => 'application/json' } - ) - rescue HTTParty::Error => e - Rails.logger.info "Unable to contact GitLab, Inc.: #{e}" - end + SubmitUsagePingService.new.execute end + private + def try_obtain_lease Gitlab::ExclusiveLease.new('gitlab_usage_ping_worker:ping', timeout: LEASE_TIMEOUT).try_obtain end - - def url - 'https://version.gitlab.com/usage_data' - end end diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index 7eb0e84acb2..7b485b3363c 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -14,7 +14,7 @@ class PipelineScheduleWorker Ci::CreatePipelineService.new(schedule.project, schedule.owner, ref: schedule.ref) - .execute(save_on_errors: false, schedule: schedule) + .execute(:schedule, save_on_errors: false, schedule: schedule) rescue => e Rails.logger.error "#{schedule.id}: Failed to create a scheduled pipeline: #{e.message}" ensure diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index d6ed0e253ad..fe6a49976e0 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -17,6 +17,7 @@ class ProcessCommitWorker project = Project.find_by(id: project_id) return unless project + return if commit_exists_in_upstream?(project, commit_hash) user = User.find_by(id: user_id) @@ -24,8 +25,6 @@ class ProcessCommitWorker commit = build_commit(project, commit_hash) - return unless commit.matches_cross_reference_regex? - author = commit.author || user process_commit_message(project, commit, user, author, default) @@ -76,4 +75,16 @@ class ProcessCommitWorker Commit.from_hash(hash, project) end + + private + + # Avoid reprocessing commits that already exist in the upstream + # when project is forked. This will also prevent duplicated system notes. + def commit_exists_in_upstream?(project, commit_hash) + return false unless project.forked? + + upstream_project = project.forked_from_project + commit_id = commit_hash.with_indifferent_access[:id] + upstream_project.commit(commit_id).present? + end end diff --git a/app/workers/remove_old_web_hook_logs_worker.rb b/app/workers/remove_old_web_hook_logs_worker.rb new file mode 100644 index 00000000000..555e1bb8691 --- /dev/null +++ b/app/workers/remove_old_web_hook_logs_worker.rb @@ -0,0 +1,10 @@ +class RemoveOldWebHookLogsWorker + include Sidekiq::Worker + include CronjobQueue + + WEB_HOOK_LOG_LIFETIME = 2.days + + def perform + WebHookLog.destroy_all(['created_at < ?', Time.now - WEB_HOOK_LOG_LIFETIME]) + end +end diff --git a/app/workers/system_hook_worker.rb b/app/workers/system_hook_worker.rb deleted file mode 100644 index 55d4e7d6dab..00000000000 --- a/app/workers/system_hook_worker.rb +++ /dev/null @@ -1,10 +0,0 @@ -class SystemHookWorker - include Sidekiq::Worker - include DedicatedSidekiqQueue - - sidekiq_options retry: 4 - - def perform(hook_id, data, hook_name) - SystemHook.find(hook_id).execute(data, hook_name) - end -end diff --git a/app/workers/project_web_hook_worker.rb b/app/workers/web_hook_worker.rb index d973e662ff2..ad5ddf02a12 100644 --- a/app/workers/project_web_hook_worker.rb +++ b/app/workers/web_hook_worker.rb @@ -1,11 +1,13 @@ -class ProjectWebHookWorker +class WebHookWorker include Sidekiq::Worker include DedicatedSidekiqQueue sidekiq_options retry: 4 def perform(hook_id, data, hook_name) + hook = WebHook.find(hook_id) data = data.with_indifferent_access - WebHook.find(hook_id).execute(data, hook_name) + + WebHookService.new(hook, data, hook_name).execute end end diff --git a/changelogs/unreleased/10378-promote-blameless-culture.yml b/changelogs/unreleased/10378-promote-blameless-culture.yml new file mode 100644 index 00000000000..8cf64dfd793 --- /dev/null +++ b/changelogs/unreleased/10378-promote-blameless-culture.yml @@ -0,0 +1,4 @@ +--- +title: Changed Blame to Annotate in the UI to promote blameless culture +merge_request: 10378 +author: Ilya Vassilevsky diff --git a/changelogs/unreleased/12614-fix-long-message-from-mr.yml b/changelogs/unreleased/12614-fix-long-message-from-mr.yml new file mode 100644 index 00000000000..30408ea4216 --- /dev/null +++ b/changelogs/unreleased/12614-fix-long-message-from-mr.yml @@ -0,0 +1,4 @@ +--- +title: Implement web hook logging +merge_request: 11027 +author: Alexander Randa 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 00000000000..bec9aa34761 --- /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 00000000000..b350b27d863 --- /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/24196-protected-variables.yml b/changelogs/unreleased/24196-protected-variables.yml new file mode 100644 index 00000000000..71567a9d794 --- /dev/null +++ b/changelogs/unreleased/24196-protected-variables.yml @@ -0,0 +1,5 @@ +--- +title: Add protected variables which would only be passed to protected branches or + protected tags +merge_request: 11688 +author: diff --git a/changelogs/unreleased/25373-jira-links.yml b/changelogs/unreleased/25373-jira-links.yml new file mode 100644 index 00000000000..09589d4b992 --- /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/25680-CI_ENVIRONMENT_URL.yml b/changelogs/unreleased/25680-CI_ENVIRONMENT_URL.yml new file mode 100644 index 00000000000..af9fe3b5041 --- /dev/null +++ b/changelogs/unreleased/25680-CI_ENVIRONMENT_URL.yml @@ -0,0 +1,4 @@ +--- +title: Add $CI_ENVIRONMENT_URL to predefined variables for pipelines +merge_request: 11695 +author: diff --git a/changelogs/unreleased/27439-memory-usage-info.yml b/changelogs/unreleased/27439-memory-usage-info.yml new file mode 100644 index 00000000000..dd212853f57 --- /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/27614-improve-instant-comments-exp.yml b/changelogs/unreleased/27614-improve-instant-comments-exp.yml new file mode 100644 index 00000000000..4db676801f1 --- /dev/null +++ b/changelogs/unreleased/27614-improve-instant-comments-exp.yml @@ -0,0 +1,4 @@ +--- +title: Improve user experience around slash commands in instant comments +merge_request: 11612 +author: diff --git a/changelogs/unreleased/28080-system-checks.yml b/changelogs/unreleased/28080-system-checks.yml new file mode 100644 index 00000000000..7d83014279a --- /dev/null +++ b/changelogs/unreleased/28080-system-checks.yml @@ -0,0 +1,4 @@ +--- +title: Refactored gitlab:app:check into SystemCheck liberary and improve some checks +merge_request: 9173 +author: diff --git a/changelogs/unreleased/28694-hard-delete-user-from-api.yml b/changelogs/unreleased/28694-hard-delete-user-from-api.yml new file mode 100644 index 00000000000..ad46540495c --- /dev/null +++ b/changelogs/unreleased/28694-hard-delete-user-from-api.yml @@ -0,0 +1,4 @@ +--- +title: Allow users to be hard-deleted from the API +merge_request: 11853 +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 00000000000..0149209caf2 --- /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/30469-convdev-index.yml b/changelogs/unreleased/30469-convdev-index.yml new file mode 100644 index 00000000000..0bdd9c4a699 --- /dev/null +++ b/changelogs/unreleased/30469-convdev-index.yml @@ -0,0 +1,4 @@ +--- +title: Add ConvDev Index page to admin area +merge_request: 11377 +author: diff --git a/changelogs/unreleased/30651-improve-container-registry-description.yml b/changelogs/unreleased/30651-improve-container-registry-description.yml new file mode 100644 index 00000000000..0157c9885bc --- /dev/null +++ b/changelogs/unreleased/30651-improve-container-registry-description.yml @@ -0,0 +1,4 @@ +--- +title: Add changelog for improved Registry description +merge_request: 11816 +author: diff --git a/changelogs/unreleased/30892-add-api-support-for-pipeline-schedule.yml b/changelogs/unreleased/30892-add-api-support-for-pipeline-schedule.yml new file mode 100644 index 00000000000..26ce84697d0 --- /dev/null +++ b/changelogs/unreleased/30892-add-api-support-for-pipeline-schedule.yml @@ -0,0 +1,4 @@ +--- +title: Add API support for pipeline schedule +merge_request: 11307 +author: dosuken123 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 00000000000..c9bd2dc465e --- /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/31061-26135-ci-project-slug-enviroment-variables.yml b/changelogs/unreleased/31061-26135-ci-project-slug-enviroment-variables.yml new file mode 100644 index 00000000000..e71910dbd67 --- /dev/null +++ b/changelogs/unreleased/31061-26135-ci-project-slug-enviroment-variables.yml @@ -0,0 +1,4 @@ +--- +title: Add slugify project path to CI enviroment variables +merge_request: 11838 +author: Ivan Chernov diff --git a/changelogs/unreleased/31448-jira-urls.yml b/changelogs/unreleased/31448-jira-urls.yml new file mode 100644 index 00000000000..d0e39f61b55 --- /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/31511-jira-settings.yml b/changelogs/unreleased/31511-jira-settings.yml new file mode 100644 index 00000000000..4f9ddb13ef6 --- /dev/null +++ b/changelogs/unreleased/31511-jira-settings.yml @@ -0,0 +1,4 @@ +--- +title: Simplify testing and saving service integrations +merge_request: 11599 +author: diff --git a/changelogs/unreleased/31556-ci-coverage-paralel-rspec.yml b/changelogs/unreleased/31556-ci-coverage-paralel-rspec.yml new file mode 100644 index 00000000000..4137050a077 --- /dev/null +++ b/changelogs/unreleased/31556-ci-coverage-paralel-rspec.yml @@ -0,0 +1,4 @@ +--- +title: Fix the last coverage in trace log should be extracted +merge_request: 11128 +author: dosuken123 diff --git a/changelogs/unreleased/31602-display-whether-shared-runner-is-enabled-in-the-admin-dashboard.yml b/changelogs/unreleased/31602-display-whether-shared-runner-is-enabled-in-the-admin-dashboard.yml new file mode 100644 index 00000000000..00957f7e4f7 --- /dev/null +++ b/changelogs/unreleased/31602-display-whether-shared-runner-is-enabled-in-the-admin-dashboard.yml @@ -0,0 +1,4 @@ +--- +title: Display Shared Runner status in Admin Dashboard +merge_request: 11783 +author: Ivan Chernov 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 00000000000..6dc48d6b2d8 --- /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/31644-make-cookie-sessions-unique.yml b/changelogs/unreleased/31644-make-cookie-sessions-unique.yml new file mode 100644 index 00000000000..e9a6a32cf70 --- /dev/null +++ b/changelogs/unreleased/31644-make-cookie-sessions-unique.yml @@ -0,0 +1,4 @@ +--- +title: Update session cookie key name to be unique to instance in development +merge_request: +author: diff --git a/changelogs/unreleased/31849-pipeline-real-time-header.yml b/changelogs/unreleased/31849-pipeline-real-time-header.yml new file mode 100644 index 00000000000..2bb7af897ff --- /dev/null +++ b/changelogs/unreleased/31849-pipeline-real-time-header.yml @@ -0,0 +1,4 @@ +--- +title: Makes header information of pipeline show page realtine +merge_request: +author: 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 00000000000..838a769a26e --- /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/31943-document-go-183.yml b/changelogs/unreleased/31943-document-go-183.yml new file mode 100644 index 00000000000..201cd48f1ab --- /dev/null +++ b/changelogs/unreleased/31943-document-go-183.yml @@ -0,0 +1,3 @@ +--- +title: Upgrade dependency to Go 1.8.3 +merge_request: 31943 diff --git a/changelogs/unreleased/32118-new-environment-btn-copy.yml b/changelogs/unreleased/32118-new-environment-btn-copy.yml new file mode 100644 index 00000000000..16a51c3db6a --- /dev/null +++ b/changelogs/unreleased/32118-new-environment-btn-copy.yml @@ -0,0 +1,4 @@ +--- +title: Make New environment empty state btn lowercase +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 00000000000..a58f3a7429e --- /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/32807-company-icon.yml b/changelogs/unreleased/32807-company-icon.yml new file mode 100644 index 00000000000..718108d3733 --- /dev/null +++ b/changelogs/unreleased/32807-company-icon.yml @@ -0,0 +1,4 @@ +--- +title: Use briefcase icon for company in profile page +merge_request: +author: diff --git a/changelogs/unreleased/32832-confidential-issue-overflow.yml b/changelogs/unreleased/32832-confidential-issue-overflow.yml new file mode 100644 index 00000000000..7d3d3bfed2e --- /dev/null +++ b/changelogs/unreleased/32832-confidential-issue-overflow.yml @@ -0,0 +1,5 @@ +--- +title: Remove overflow from comment form for confidential issues and vertically aligns + confidential issue icon +merge_request: +author: diff --git a/changelogs/unreleased/32851-postgres-min-version.yml b/changelogs/unreleased/32851-postgres-min-version.yml new file mode 100644 index 00000000000..139307d65c6 --- /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/32983-merge-conflict-resolution-removed-the-newline-in-the-end-of-file.yml b/changelogs/unreleased/32983-merge-conflict-resolution-removed-the-newline-in-the-end-of-file.yml new file mode 100644 index 00000000000..eca42176501 --- /dev/null +++ b/changelogs/unreleased/32983-merge-conflict-resolution-removed-the-newline-in-the-end-of-file.yml @@ -0,0 +1,4 @@ +--- +title: Keep trailing newline when resolving conflicts by picking sides +merge_request: +author: diff --git a/changelogs/unreleased/32992-consider-using-zopfli-over-standard-gzip-compression-for-webpack-assets.yml b/changelogs/unreleased/32992-consider-using-zopfli-over-standard-gzip-compression-for-webpack-assets.yml new file mode 100644 index 00000000000..93037d6181e --- /dev/null +++ b/changelogs/unreleased/32992-consider-using-zopfli-over-standard-gzip-compression-for-webpack-assets.yml @@ -0,0 +1,4 @@ +--- +title: Use zopfli compression for frontend assets +merge_request: 11798 +author: diff --git a/changelogs/unreleased/33000-tag-list-in-project-create-api.yml b/changelogs/unreleased/33000-tag-list-in-project-create-api.yml new file mode 100644 index 00000000000..b0d0d3cbeba --- /dev/null +++ b/changelogs/unreleased/33000-tag-list-in-project-create-api.yml @@ -0,0 +1,4 @@ +--- +title: Add tag_list param to project api +merge_request: 11799 +author: Ivan Chernov 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 00000000000..1eaa0d0124e --- /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 00000000000..5648e013e75 --- /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/33207-show-delete-option-in-admin-users-page.yml b/changelogs/unreleased/33207-show-delete-option-in-admin-users-page.yml new file mode 100644 index 00000000000..5eb4e15e311 --- /dev/null +++ b/changelogs/unreleased/33207-show-delete-option-in-admin-users-page.yml @@ -0,0 +1,4 @@ +--- +title: Allow admins to delete users from the admin users page +merge_request: 11852 +author: diff --git a/changelogs/unreleased/33215-fix-hard-delete-of-users.yml b/changelogs/unreleased/33215-fix-hard-delete-of-users.yml new file mode 100644 index 00000000000..29699ff745a --- /dev/null +++ b/changelogs/unreleased/33215-fix-hard-delete-of-users.yml @@ -0,0 +1,4 @@ +--- +title: Fix hard-deleting users when they have authored issues +merge_request: 11855 +author: diff --git a/changelogs/unreleased/33242-create-project-for-user-api-ignores-path-parameter.yml b/changelogs/unreleased/33242-create-project-for-user-api-ignores-path-parameter.yml new file mode 100644 index 00000000000..c33278998ee --- /dev/null +++ b/changelogs/unreleased/33242-create-project-for-user-api-ignores-path-parameter.yml @@ -0,0 +1,4 @@ +--- +title: Fix missing optional path parameter in "Create project for user" API +merge_request: 11868 +author: diff --git a/changelogs/unreleased/UI-improvements-for-count-badges-and-permission-badges.yml b/changelogs/unreleased/UI-improvements-for-count-badges-and-permission-badges.yml new file mode 100644 index 00000000000..374f643faa7 --- /dev/null +++ b/changelogs/unreleased/UI-improvements-for-count-badges-and-permission-badges.yml @@ -0,0 +1,5 @@ +--- +title: Count badges depend on translucent color to better adjust to different background + colors and permission badges now feature a pill shaped design similar to labels +merge_request: +author: diff --git a/changelogs/unreleased/aliyun-backup-provider.yml b/changelogs/unreleased/aliyun-backup-provider.yml new file mode 100644 index 00000000000..e7505e44a59 --- /dev/null +++ b/changelogs/unreleased/aliyun-backup-provider.yml @@ -0,0 +1,4 @@ +--- +title: Add Aliyun OSS as the backup storage provider +merge_request: 9721 +author: Yuanfei Zhu diff --git a/changelogs/unreleased/bugfix-v3-deploy_keys-can_push.yml b/changelogs/unreleased/bugfix-v3-deploy_keys-can_push.yml new file mode 100644 index 00000000000..0306663ac8d --- /dev/null +++ b/changelogs/unreleased/bugfix-v3-deploy_keys-can_push.yml @@ -0,0 +1,4 @@ +--- +title: "Fixed handling of the `can_push` attribute in the v3 deploy_keys api" +merge_request: 11607 +author: Richard Clamp 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 00000000000..2bbff2fdd16 --- /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-comment-on-mr-commit-discussion.yml b/changelogs/unreleased/dm-comment-on-mr-commit-discussion.yml new file mode 100644 index 00000000000..50db66c89ba --- /dev/null +++ b/changelogs/unreleased/dm-comment-on-mr-commit-discussion.yml @@ -0,0 +1,4 @@ +--- +title: Fix replying to a commit discussion displayed in the context of an MR +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 00000000000..acc17cb4523 --- /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-discussions-n-plus-1.yml b/changelogs/unreleased/dm-discussions-n-plus-1.yml new file mode 100644 index 00000000000..b97e4344248 --- /dev/null +++ b/changelogs/unreleased/dm-discussions-n-plus-1.yml @@ -0,0 +1,4 @@ +--- +title: Resolve N+1 query issue with discussions +merge_request: +author: diff --git a/changelogs/unreleased/dm-emails-are-not-user-references.yml b/changelogs/unreleased/dm-emails-are-not-user-references.yml new file mode 100644 index 00000000000..fe55a75a88f --- /dev/null +++ b/changelogs/unreleased/dm-emails-are-not-user-references.yml @@ -0,0 +1,4 @@ +--- +title: Don't match email addresses or foo@bar as user references +merge_request: +author: diff --git a/changelogs/unreleased/dm-fix-jump-button.yml b/changelogs/unreleased/dm-fix-jump-button.yml new file mode 100644 index 00000000000..4cde354fa28 --- /dev/null +++ b/changelogs/unreleased/dm-fix-jump-button.yml @@ -0,0 +1,4 @@ +--- +title: Fix title of discussion jump button at top of page +merge_request: +author: diff --git a/changelogs/unreleased/dm-gitmodules-parsing.yml b/changelogs/unreleased/dm-gitmodules-parsing.yml new file mode 100644 index 00000000000..a7d755d6c4d --- /dev/null +++ b/changelogs/unreleased/dm-gitmodules-parsing.yml @@ -0,0 +1,4 @@ +--- +title: Make .gitmodules parsing more resilient to syntax errors +merge_request: +author: diff --git a/changelogs/unreleased/dm-gravatar-username.yml b/changelogs/unreleased/dm-gravatar-username.yml new file mode 100644 index 00000000000..d50455061ec --- /dev/null +++ b/changelogs/unreleased/dm-gravatar-username.yml @@ -0,0 +1,4 @@ +--- +title: Add username parameter to gravatar URL +merge_request: +author: diff --git a/changelogs/unreleased/dm-more-dependency-linkers.yml b/changelogs/unreleased/dm-more-dependency-linkers.yml new file mode 100644 index 00000000000..12d45e71e85 --- /dev/null +++ b/changelogs/unreleased/dm-more-dependency-linkers.yml @@ -0,0 +1,4 @@ +--- +title: Autolink package names in more dependency files +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 00000000000..8fbbd45bb57 --- /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/feature-flags-flipper.yml b/changelogs/unreleased/feature-flags-flipper.yml new file mode 100644 index 00000000000..5be5c44166d --- /dev/null +++ b/changelogs/unreleased/feature-flags-flipper.yml @@ -0,0 +1,4 @@ +--- +title: Add feature toggles and API endpoints for admins +merge_request: 11747 +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 00000000000..e40668546c0 --- /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/fix-gb-use-merge-ability-for-protected-manual-actions.yml b/changelogs/unreleased/fix-gb-use-merge-ability-for-protected-manual-actions.yml new file mode 100644 index 00000000000..43c18502cd6 --- /dev/null +++ b/changelogs/unreleased/fix-gb-use-merge-ability-for-protected-manual-actions.yml @@ -0,0 +1,4 @@ +--- +title: Respect merge, instead of push, permissions for protected actions +merge_request: 11648 +author: diff --git a/changelogs/unreleased/fix-n-plus-one-queries-for-user-access.yml b/changelogs/unreleased/fix-n-plus-one-queries-for-user-access.yml new file mode 100644 index 00000000000..c2671a96b83 --- /dev/null +++ b/changelogs/unreleased/fix-n-plus-one-queries-for-user-access.yml @@ -0,0 +1,4 @@ +--- +title: Fix N+1 queries for non-members in comment threads +merge_request: +author: diff --git a/changelogs/unreleased/fix-terminals-support-for-kubernetes-service.yml b/changelogs/unreleased/fix-terminals-support-for-kubernetes-service.yml new file mode 100644 index 00000000000..fb91da9510c --- /dev/null +++ b/changelogs/unreleased/fix-terminals-support-for-kubernetes-service.yml @@ -0,0 +1,4 @@ +--- +title: Fix terminals support for Kubernetes Service +merge_request: +author: diff --git a/changelogs/unreleased/fix_diff_line_comments.yml b/changelogs/unreleased/fix_diff_line_comments.yml new file mode 100644 index 00000000000..bdb0539b49d --- /dev/null +++ b/changelogs/unreleased/fix_diff_line_comments.yml @@ -0,0 +1,5 @@ +--- +title: 'Fix: A diff comment on a change at last line of a file shows as two comments + in discussion' +merge_request: +author: diff --git a/changelogs/unreleased/gitaly-opt-out.yml b/changelogs/unreleased/gitaly-opt-out.yml new file mode 100644 index 00000000000..2f89e0bfc9a --- /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/introduce-source-to-pipelines.yml b/changelogs/unreleased/introduce-source-to-pipelines.yml new file mode 100644 index 00000000000..7898bd31b39 --- /dev/null +++ b/changelogs/unreleased/introduce-source-to-pipelines.yml @@ -0,0 +1,4 @@ +--- +title: Introduce source to Pipeline entity +merge_request: +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 00000000000..54b818d6d5e --- /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-edit-inline.yml b/changelogs/unreleased/issue-edit-inline.yml new file mode 100644 index 00000000000..db03d1bdac4 --- /dev/null +++ b/changelogs/unreleased/issue-edit-inline.yml @@ -0,0 +1,4 @@ +--- +title: Enables inline editing for an issues title & description +merge_request: +author: diff --git a/changelogs/unreleased/issue-template-reproduce-in-example-project.yml b/changelogs/unreleased/issue-template-reproduce-in-example-project.yml new file mode 100644 index 00000000000..8116007b459 --- /dev/null +++ b/changelogs/unreleased/issue-template-reproduce-in-example-project.yml @@ -0,0 +1,4 @@ +--- +title: Ask for an example project for bug reports +merge_request: +author: diff --git a/changelogs/unreleased/issue_19262.yml b/changelogs/unreleased/issue_19262.yml new file mode 100644 index 00000000000..7bcbc647fcb --- /dev/null +++ b/changelogs/unreleased/issue_19262.yml @@ -0,0 +1,4 @@ +--- +title: Prevent commits from upstream repositories to be re-processed by forks +merge_request: +author: diff --git a/changelogs/unreleased/issue_32225_2.yml b/changelogs/unreleased/issue_32225_2.yml new file mode 100644 index 00000000000..320b9fe00b8 --- /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/jouve-gitlab-ce-admin_keys.yml b/changelogs/unreleased/jouve-gitlab-ce-admin_keys.yml new file mode 100644 index 00000000000..df4de9f4e21 --- /dev/null +++ b/changelogs/unreleased/jouve-gitlab-ce-admin_keys.yml @@ -0,0 +1,5 @@ +--- +title: Redirect to user's keys index instead of user's index after a key is deleted + in the admin +merge_request: 10227 +author: Cyril Jouve diff --git a/changelogs/unreleased/mabes-gitlab-ce-bypass-auto-login.yml b/changelogs/unreleased/mabes-gitlab-ce-bypass-auto-login.yml new file mode 100644 index 00000000000..a321ed9d7d8 --- /dev/null +++ b/changelogs/unreleased/mabes-gitlab-ce-bypass-auto-login.yml @@ -0,0 +1,4 @@ +--- +title: Allow manual bypass of auto_sign_in_with_provider with a new param +merge_request: 10187 +author: Maxime Besson diff --git a/changelogs/unreleased/migrate-artifacts-to-a-new-path.yml b/changelogs/unreleased/migrate-artifacts-to-a-new-path.yml new file mode 100644 index 00000000000..bd022a3a91b --- /dev/null +++ b/changelogs/unreleased/migrate-artifacts-to-a-new-path.yml @@ -0,0 +1,4 @@ +--- +title: Migrate artifacts to a new path +merge_request: +author: diff --git a/changelogs/unreleased/projects-api-import-status.yml b/changelogs/unreleased/projects-api-import-status.yml new file mode 100644 index 00000000000..06603c0adec --- /dev/null +++ b/changelogs/unreleased/projects-api-import-status.yml @@ -0,0 +1,4 @@ +--- +title: Expose import_status in Projects API +merge_request: 11851 +author: Robin Bobbitt diff --git a/changelogs/unreleased/rename-builds-controller.yml b/changelogs/unreleased/rename-builds-controller.yml new file mode 100644 index 00000000000..7f6872ccf95 --- /dev/null +++ b/changelogs/unreleased/rename-builds-controller.yml @@ -0,0 +1,4 @@ +--- +title: Change /builds in the URL to /-/jobs. Backward URLs were also added +merge_request: 11407 +author: diff --git a/changelogs/unreleased/rework-authorizations-performance.yml b/changelogs/unreleased/rework-authorizations-performance.yml new file mode 100644 index 00000000000..f64257a6f56 --- /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-lfs-from-moving-across-filesystems.yml b/changelogs/unreleased/sh-fix-lfs-from-moving-across-filesystems.yml new file mode 100644 index 00000000000..161bce45601 --- /dev/null +++ b/changelogs/unreleased/sh-fix-lfs-from-moving-across-filesystems.yml @@ -0,0 +1,4 @@ +--- +title: Fix LFS timeouts when trying to save large files +merge_request: +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 00000000000..d633995d467 --- /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/changelogs/unreleased/task-list-2.yml b/changelogs/unreleased/task-list-2.yml new file mode 100644 index 00000000000..cbae8178081 --- /dev/null +++ b/changelogs/unreleased/task-list-2.yml @@ -0,0 +1,4 @@ +--- +title: Update task_list to version 2.0.0 +merge_request: 11525 +author: Jared Deckard <jared.deckard@gmail.com> diff --git a/changelogs/unreleased/tc-improve-project-api-perf.yml b/changelogs/unreleased/tc-improve-project-api-perf.yml new file mode 100644 index 00000000000..7e88466c058 --- /dev/null +++ b/changelogs/unreleased/tc-improve-project-api-perf.yml @@ -0,0 +1,4 @@ +--- +title: Improve performance of ProjectFinder used in /projects API endpoint +merge_request: 11666 +author: diff --git a/changelogs/unreleased/winh-current-user-filter.yml b/changelogs/unreleased/winh-current-user-filter.yml new file mode 100644 index 00000000000..e5409827b31 --- /dev/null +++ b/changelogs/unreleased/winh-current-user-filter.yml @@ -0,0 +1,4 @@ +--- +title: Show current user immediately in issuable filters +merge_request: 11630 +author: diff --git a/changelogs/unreleased/winh-styled-people-search-bar.yml b/changelogs/unreleased/winh-styled-people-search-bar.yml new file mode 100644 index 00000000000..a088af37d8d --- /dev/null +++ b/changelogs/unreleased/winh-styled-people-search-bar.yml @@ -0,0 +1,4 @@ +--- +title: Style people in issuable search bar +merge_request: 11402 +author: diff --git a/changelogs/unreleased/zj-drop-fk-if-exists.yml b/changelogs/unreleased/zj-drop-fk-if-exists.yml new file mode 100644 index 00000000000..237ba936de9 --- /dev/null +++ b/changelogs/unreleased/zj-drop-fk-if-exists.yml @@ -0,0 +1,4 @@ +--- +title: Remove foreigh key on ci_trigger_schedules only if it exists +merge_request: +author: diff --git a/changelogs/unreleased/zj-job-view-goes-real-time.yml b/changelogs/unreleased/zj-job-view-goes-real-time.yml new file mode 100644 index 00000000000..376c9dfa65f --- /dev/null +++ b/changelogs/unreleased/zj-job-view-goes-real-time.yml @@ -0,0 +1,4 @@ +--- +title: Job details page update real time +merge_request: 11651 +author: diff --git a/changelogs/unreleased/zj-realtime-env-list.yml b/changelogs/unreleased/zj-realtime-env-list.yml new file mode 100644 index 00000000000..6460d17edc9 --- /dev/null +++ b/changelogs/unreleased/zj-realtime-env-list.yml @@ -0,0 +1,4 @@ +--- +title: Make environment table realtime +merge_request: 11333 +author: diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index a727f7e2fa3..d2aeb66ebf6 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -169,7 +169,7 @@ production: &base ## Gravatar ## For Libravatar see: http://doc.gitlab.com/ce/customization/libravatar.html gravatar: - # gravatar urls: possible placeholders: %{hash} %{size} %{email} + # gravatar urls: possible placeholders: %{hash} %{size} %{email} %{username} # plain_url: "http://..." # default: http://www.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon # ssl_url: "https://..." # default: https://secure.gravatar.com/avatar/%{hash}?s=%{size}&d=identicon @@ -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 index c564c0cab11..54e9fcc31db 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 5a90830b5b3..45ea2040d23 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -368,11 +368,14 @@ Settings.cron_jobs['gitlab_usage_ping_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['gitlab_usage_ping_worker']['cron'] ||= Settings.__send__(:cron_random_weekly_time) Settings.cron_jobs['gitlab_usage_ping_worker']['job_class'] = 'GitlabUsagePingWorker' -# Every day at 00:30 Settings.cron_jobs['schedule_update_user_activity_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['schedule_update_user_activity_worker']['cron'] ||= '30 0 * * *' Settings.cron_jobs['schedule_update_user_activity_worker']['job_class'] = 'ScheduleUpdateUserActivityWorker' +Settings.cron_jobs['remove_old_web_hook_logs_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['remove_old_web_hook_logs_worker']['cron'] ||= '40 0 * * *' +Settings.cron_jobs['remove_old_web_hook_logs_worker']['job_class'] = 'RemoveOldWebHookLogsWorker' + # # GitLab Shell # @@ -479,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_monkey_patch.rb b/config/initializers/active_record_locking.rb index 9266ff0f615..9266ff0f615 100644 --- a/config/initializers/ar_monkey_patch.rb +++ b/config/initializers/active_record_locking.rb diff --git a/config/initializers/active_record_preloader.rb b/config/initializers/active_record_preloader.rb new file mode 100644 index 00000000000..3b16014f302 --- /dev/null +++ b/config/initializers/active_record_preloader.rb @@ -0,0 +1,15 @@ +module ActiveRecord + module Associations + class Preloader + module NoCommitPreloader + def preloader_for(reflection, owners, rhs_klass) + return NullPreloader if rhs_klass == ::Commit + + super + end + end + + prepend NoCommitPreloader + end + end +end diff --git a/config/initializers/ar_speed_up_migration_checking.rb b/config/initializers/ar_speed_up_migration_checking.rb index 1fe5defc01d..aae774daa35 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/fast_gettext.rb b/config/initializers/fast_gettext.rb index a69fe0c902e..eb589ecdb52 100644 --- a/config/initializers/fast_gettext.rb +++ b/config/initializers/fast_gettext.rb @@ -1,5 +1,6 @@ FastGettext.add_text_domain 'gitlab', path: File.join(Rails.root, 'locale'), type: :po FastGettext.default_text_domain = 'gitlab' FastGettext.default_available_locales = Gitlab::I18n.available_locales +FastGettext.default_locale = :en I18n.available_locales = Gitlab::I18n.available_locales diff --git a/config/initializers/forbid_sidekiq_in_transactions.rb b/config/initializers/forbid_sidekiq_in_transactions.rb new file mode 100644 index 00000000000..a78711fe599 --- /dev/null +++ b/config/initializers/forbid_sidekiq_in_transactions.rb @@ -0,0 +1,49 @@ +module Sidekiq + module Worker + mattr_accessor :skip_transaction_check + self.skip_transaction_check = false + + def self.skipping_transaction_check(&block) + skip_transaction_check = self.skip_transaction_check + self.skip_transaction_check = true + yield + ensure + self.skip_transaction_check = skip_transaction_check + end + + module ClassMethods + module NoSchedulingFromTransactions + NESTING = ::Rails.env.test? ? 1 : 0 + + %i(perform_async perform_at perform_in).each do |name| + define_method(name) do |*args| + return super(*args) if Sidekiq::Worker.skip_transaction_check + return super(*args) unless ActiveRecord::Base.connection.open_transactions > NESTING + + raise <<-MSG.strip_heredoc + `#{self}.#{name}` cannot be called inside a transaction as this can lead to + race conditions when the worker runs before the transaction is committed and + tries to access a model that has not been saved yet. + + Use an `after_commit` hook, or include `AfterCommitQueue` and use a `run_after_commit` block instead. + MSG + end + end + end + + prepend NoSchedulingFromTransactions + end + end +end + +module ActiveRecord + class Base + module SkipTransactionCheckAfterCommit + def committed!(*) + Sidekiq::Worker.skipping_transaction_check { super } + end + end + + prepend SkipTransactionCheckAfterCommit + end +end diff --git a/config/initializers/postgresql_cte.rb b/config/initializers/postgresql_cte.rb new file mode 100644 index 00000000000..7f0df8949db --- /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/relative_naming_ci_namespace.rb b/config/initializers/relative_naming_ci_namespace.rb index 59abe1b9b91..03ac55be0b6 100644 --- a/config/initializers/relative_naming_ci_namespace.rb +++ b/config/initializers/relative_naming_ci_namespace.rb @@ -4,7 +4,7 @@ # - [project.namespace, project, build] # # instead of: -# - namespace_project_build_path(project.namespace, project, build) +# - namespace_project_job_path(project.namespace, project, build) # # Without that, Ci:: namespace is used for resolving routes: # - namespace_project_ci_build_path(project.namespace, project, build) diff --git a/config/initializers/server_uptime.rb b/config/initializers/server_uptime.rb new file mode 100644 index 00000000000..46bf242e143 --- /dev/null +++ b/config/initializers/server_uptime.rb @@ -0,0 +1 @@ +Rails.application.config.booted_at = Time.now diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index 70be2617cab..8919f7640fe 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -10,6 +10,12 @@ rescue Settings.gitlab['session_expire_delay'] ||= 10080 end +cookie_key = if Rails.env.development? + "_gitlab_session_#{Digest::SHA256.hexdigest(Rails.root.to_s)}" + else + "_gitlab_session" + end + if Rails.env.test? Gitlab::Application.config.session_store :cookie_store, key: "_gitlab_session" else @@ -19,7 +25,7 @@ else Gitlab::Application.config.session_store( :redis_store, # Using the cookie_store would enable session replay attacks. servers: redis_config, - key: '_gitlab_session', + key: cookie_key, secure: Gitlab.config.gitlab.https, httponly: true, expires_in: Settings.gitlab['session_expire_delay'] * 60, diff --git a/config/karma.config.js b/config/karma.config.js index eb082dd28bf..40c58e7771d 100644 --- a/config/karma.config.js +++ b/config/karma.config.js @@ -13,6 +13,8 @@ if (webpackConfig.plugins) { }); } +webpackConfig.devtool = 'cheap-inline-source-map'; + // Karma configuration module.exports = function(config) { var progressReporter = process.env.CI ? 'mocha' : 'progress'; diff --git a/config/locales/en.yml b/config/locales/en.yml index 12a59be79f0..9d47425950a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -13,3 +13,39 @@ en: pagination: previous: "Prev" next: "Next" + datetime: + time_ago_in_words: + half_a_minute: "half a minute ago" + less_than_x_seconds: + one: "less than 1 second ago" + other: "less than %{count} seconds ago" + x_seconds: + one: "1 second ago" + other: "%{count} seconds ago" + less_than_x_minutes: + one: "less than a minute ago" + other: "less than %{count} minutes ago" + x_minutes: + one: "1 minute ago" + other: "%{count} minutes ago" + about_x_hours: + one: "about 1 hour ago" + other: "about %{count} hours ago" + x_days: + one: "1 day ago" + other: "%{count} days ago" + about_x_months: + one: "about 1 month ago" + other: "about %{count} months ago" + x_months: + one: "1 month ago" + other: "%{count} months ago" + about_x_years: + one: "about 1 year ago" + other: "about %{count} years ago" + over_x_years: + one: "over 1 year ago" + other: "over %{count} years ago" + almost_x_years: + one: "almost 1 year ago" + other: "almost %{count} years ago" diff --git a/config/locales/es.yml b/config/locales/es.yml index 87e79beee74..0f9dc39535d 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -61,6 +61,41 @@ es: - :month - :year datetime: + time_ago_in_words: + half_a_minute: "hace medio minuto" + less_than_x_seconds: + one: "hace menos de 1 segundo" + other: "hace menos de %{count} segundos" + x_seconds: + one: "hace 1 segundo" + other: "hace %{count} segundos" + less_than_x_minutes: + one: "hace menos de un minuto" + other: "hace menos de %{count} minutos" + x_minutes: + one: "hace 1 minuto" + other: "hace %{count} minutos" + about_x_hours: + one: "hace alrededor de 1 hora" + other: "hace alrededor de %{count} horas" + x_days: + one: "hace un dÃa" + other: "hace %{count} dÃas" + about_x_months: + one: "hace alrededor de 1 mes" + other: "hace alrededor de %{count} meses" + x_months: + one: "hace 1 mes" + other: "hace %{count} meses" + about_x_years: + one: "hace alrededor de 1 año" + other: "hace alrededor de %{count} años" + over_x_years: + one: "hace más de 1 año" + other: "hace %{count} años" + almost_x_years: + one: "hace casi 1 año" + other: "hace casi %{count} años" distance_in_words: about_x_hours: one: alrededor de 1 hora diff --git a/config/routes.rb b/config/routes.rb index 2584981bb04..846054e6917 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,5 @@ require 'sidekiq/web' require 'sidekiq/cron/web' -require 'constraints/group_url_constrainer' Rails.application.routes.draw do concern :access_requestable do @@ -85,20 +84,6 @@ Rails.application.routes.draw do root to: "root#index" - # Since group show page is wildcard routing - # we want all other routing to be checked before matching this one - constraints(GroupUrlConstrainer.new) do - scope(path: '*id', - as: :group, - constraints: { id: Gitlab::Regex.namespace_route_regex, format: /(html|json|atom)/ }, - controller: :groups) do - get '/', action: :show - patch '/', action: :update - put '/', action: :update - delete '/', action: :destroy - end - end - draw :test if Rails.env.test? get '*unmatched_route', to: 'application#route_not_found' diff --git a/config/routes/admin.rb b/config/routes/admin.rb index b1b6ef33a47..c7b639b7b3c 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -36,7 +36,7 @@ namespace :admin do scope(path: 'groups/*id', controller: :groups, - constraints: { id: Gitlab::Regex.namespace_route_regex, format: /(html|json|atom)/ }) do + constraints: { id: Gitlab::PathRegex.full_namespace_route_regex, format: /(html|json|atom)/ }) do scope(as: :group) do put :members_update @@ -54,6 +54,12 @@ namespace :admin do member do get :test end + + resources :hook_logs, only: [:show] do + member do + get :retry + end + end end resources :broadcast_messages, only: [:index, :edit, :create, :update, :destroy] do @@ -66,14 +72,16 @@ namespace :admin do resource :system_info, controller: 'system_info', only: [:show] resources :requests_profiles, only: [:index, :show], param: :name, constraints: { name: /.+\.html/ } + get 'conversational_development_index' => 'conversational_development_index#show' + resources :projects, only: [:index] scope(path: 'projects/*namespace_id', as: :namespace, - constraints: { namespace_id: Gitlab::Regex.namespace_route_regex }) do + constraints: { namespace_id: Gitlab::PathRegex.full_namespace_route_regex }) do resources(:projects, path: '/', - constraints: { id: Gitlab::Regex.project_route_regex }, + constraints: { id: Gitlab::PathRegex.project_route_regex }, only: [:show]) do member do @@ -112,7 +120,7 @@ namespace :admin do resources :cohorts, only: :index - resources :builds, only: :index do + resources :jobs, only: :index do collection do post :cancel_all end diff --git a/config/routes/git_http.rb b/config/routes/git_http.rb index cdf658c3e4a..a53c94326d4 100644 --- a/config/routes/git_http.rb +++ b/config/routes/git_http.rb @@ -1,7 +1,7 @@ scope(path: '*namespace_id/:project_id', format: nil, - constraints: { namespace_id: Gitlab::Regex.namespace_route_regex }) do - scope(constraints: { project_id: Gitlab::Regex.project_git_route_regex }, module: :projects) do + constraints: { namespace_id: Gitlab::PathRegex.full_namespace_route_regex }) do + scope(constraints: { project_id: Gitlab::PathRegex.project_git_route_regex }, module: :projects) do # Git HTTP clients ('git clone' etc.) scope(controller: :git_http) do get '/info/refs', action: :info_refs @@ -28,7 +28,7 @@ scope(path: '*namespace_id/:project_id', end # Redirect /group/project/info/refs to /group/project.git/info/refs - scope(constraints: { project_id: Gitlab::Regex.project_route_regex }) do + scope(constraints: { project_id: Gitlab::PathRegex.project_route_regex }) do # Allow /info/refs, /info/refs?service=git-upload-pack, and # /info/refs?service=git-receive-pack, but nothing else. # diff --git a/config/routes/group.rb b/config/routes/group.rb index 7b29e0e807c..11cdff55ed8 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -1,9 +1,11 @@ +require 'constraints/group_url_constrainer' + resources :groups, only: [:index, :new, :create] scope(path: 'groups/*group_id', module: :groups, as: :group, - constraints: { group_id: Gitlab::Regex.namespace_route_regex }) do + constraints: { group_id: Gitlab::PathRegex.full_namespace_route_regex }) do resources :group_members, only: [:index, :create, :update, :destroy], concerns: :access_requestable do post :resend_invite, on: :member delete :leave, on: :collection @@ -25,7 +27,7 @@ end scope(path: 'groups/*id', controller: :groups, - constraints: { id: Gitlab::Regex.namespace_route_regex, format: /(html|json|atom)/ }) do + constraints: { id: Gitlab::PathRegex.full_namespace_route_regex, format: /(html|json|atom)/ }) do get :edit, as: :edit_group get :issues, as: :issues_group get :merge_requests, as: :merge_requests_group @@ -34,3 +36,15 @@ scope(path: 'groups/*id', get :subgroups, as: :subgroups_group get '/', action: :show, as: :group_canonical end + +constraints(GroupUrlConstrainer.new) do + scope(path: '*id', + as: :group, + constraints: { id: Gitlab::PathRegex.full_namespace_route_regex, format: /(html|json|atom)/ }, + controller: :groups) do + get '/', action: :show + patch '/', action: :update + put '/', action: :update + delete '/', action: :destroy + end +end diff --git a/config/routes/project.rb b/config/routes/project.rb index 9fe8372edf9..14718e2f3c4 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -1,4 +1,5 @@ require 'constraints/project_url_constrainer' +require 'gitlab/routes/legacy_builds' resources :projects, only: [:index, :new, :create] @@ -13,16 +14,16 @@ constraints(ProjectUrlConstrainer.new) do # Otherwise, Rails will overwrite the constraint with `/.+?/`, # which breaks some of our wildcard routes like `/blob/*id` # and `/tree/*id` that depend on the negative lookahead inside - # `Gitlab::Regex.namespace_route_regex`, which helps the router + # `Gitlab::PathRegex.full_namespace_route_regex`, which helps the router # determine whether a certain path segment is part of `*namespace_id`, # `:project_id`, or `*id`. # # See https://github.com/rails/rails/blob/v4.2.8/actionpack/lib/action_dispatch/routing/mapper.rb#L155 scope(path: '*namespace_id', as: :namespace, - namespace_id: Gitlab::Regex.namespace_route_regex) do + namespace_id: Gitlab::PathRegex.full_namespace_route_regex) do scope(path: ':project_id', - constraints: { project_id: Gitlab::Regex.project_route_regex }, + constraints: { project_id: Gitlab::PathRegex.project_route_regex }, module: :projects, as: :project) do @@ -66,7 +67,7 @@ constraints(ProjectUrlConstrainer.new) do resources :services, constraints: { id: /[^\/]+/ }, only: [:index, :edit, :update] do member do - get :test + put :test end end @@ -180,42 +181,52 @@ constraints(ProjectUrlConstrainer.new) do end end - resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do - collection do - post :cancel_all - - resources :artifacts, only: [] do - collection do - get :latest_succeeded, - path: '*ref_name_and_path', - format: false + scope '-' do + resources :jobs, only: [:index, :show], constraints: { id: /\d+/ } do + collection do + post :cancel_all + + resources :artifacts, only: [] do + collection do + get :latest_succeeded, + path: '*ref_name_and_path', + format: false + end end end - end - member do - get :status - post :cancel - post :retry - post :play - post :erase - get :trace, defaults: { format: 'json' } - get :raw - end + member do + get :status + post :cancel + post :retry + post :play + post :erase + get :trace, defaults: { format: 'json' } + get :raw + end - resource :artifacts, only: [] do - get :download - get :browse, path: 'browse(/*path)', format: false - get :file, path: 'file/*path', format: false - get :raw, path: 'raw/*path', format: false - post :keep + resource :artifacts, only: [] do + get :download + get :browse, path: 'browse(/*path)', format: false + get :file, path: 'file/*path', format: false + get :raw, path: 'raw/*path', format: false + post :keep + end end end + Gitlab::Routes::LegacyBuilds.new(self).draw + resources :hooks, only: [:index, :create, :edit, :update, :destroy], constraints: { id: /\d+/ } do member do get :test end + + resources :hook_logs, only: [:show] do + member do + get :retry + end + end end resources :container_registry, only: [:index, :destroy], @@ -329,7 +340,7 @@ constraints(ProjectUrlConstrainer.new) do resources :runner_projects, only: [:create, :destroy] resources :badges, only: [:index] do collection do - scope '*ref', constraints: { ref: Gitlab::Regex.git_reference_regex } do + scope '*ref', constraints: { ref: Gitlab::PathRegex.git_reference_regex } do constraints format: /svg/ do get :build get :coverage @@ -352,7 +363,7 @@ constraints(ProjectUrlConstrainer.new) do resources(:projects, path: '/', - constraints: { id: Gitlab::Regex.project_route_regex }, + constraints: { id: Gitlab::PathRegex.project_route_regex }, only: [:edit, :show, :update, :destroy]) do member do put :transfer diff --git a/config/routes/repository.rb b/config/routes/repository.rb index 5cf37a06e97..11911636fa7 100644 --- a/config/routes/repository.rb +++ b/config/routes/repository.rb @@ -2,7 +2,7 @@ resource :repository, only: [:create] do member do - get 'archive', constraints: { format: Gitlab::Regex.archive_formats_regex } + get 'archive', constraints: { format: Gitlab::PathRegex.archive_formats_regex } end end @@ -24,7 +24,7 @@ scope format: false do member do # tree viewer logs - get 'logs_tree', constraints: { id: Gitlab::Regex.git_reference_regex } + get 'logs_tree', constraints: { id: Gitlab::PathRegex.git_reference_regex } # Directories with leading dots erroneously get rejected if git # ref regex used in constraints. Regex verification now done in controller. get 'logs_tree/*path', action: :logs_tree, as: :logs_file, format: false, constraints: { @@ -34,7 +34,7 @@ scope format: false do end end - scope constraints: { id: Gitlab::Regex.git_reference_regex } do + scope constraints: { id: Gitlab::PathRegex.git_reference_regex } do resources :network, only: [:show] resources :graphs, only: [:show] do diff --git a/config/routes/user.rb b/config/routes/user.rb index 0f3bec9cf58..e682dcd6663 100644 --- a/config/routes/user.rb +++ b/config/routes/user.rb @@ -11,19 +11,7 @@ devise_scope :user do get '/users/almost_there' => 'confirmations#almost_there' end -constraints(UserUrlConstrainer.new) do - # Get all keys of user - get ':username.keys' => 'profiles/keys#get_keys', constraints: { username: Gitlab::Regex.root_namespace_route_regex } - - scope(path: ':username', - as: :user, - constraints: { username: Gitlab::Regex.root_namespace_route_regex }, - controller: :users) do - get '/', action: :show - end -end - -scope(constraints: { username: Gitlab::Regex.root_namespace_route_regex }) do +scope(constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }) do scope(path: 'users/:username', as: :user, controller: :users) do @@ -34,7 +22,7 @@ scope(constraints: { username: Gitlab::Regex.root_namespace_route_regex }) do get :contributed, as: :contributed_projects get :snippets get :exists - get '/', to: redirect('/%{username}') + get '/', to: redirect('/%{username}'), as: nil end # Compatibility with old routing @@ -46,3 +34,15 @@ scope(constraints: { username: Gitlab::Regex.root_namespace_route_regex }) do get '/u/:username/snippets', to: redirect('/users/%{username}/snippets') get '/u/:username/contributed', to: redirect('/users/%{username}/contributed') end + +constraints(UserUrlConstrainer.new) do + # Get all keys of user + get ':username.keys' => 'profiles/keys#get_keys', constraints: { username: Gitlab::PathRegex.root_namespace_route_regex } + + scope(path: ':username', + as: :user, + constraints: { username: Gitlab::PathRegex.root_namespace_route_regex }, + controller: :users) do + get '/', action: :show + end +end diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 0ca1f565185..93df2d6f5ff 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -44,9 +44,8 @@ - [project_cache, 1] - [project_destroy, 1] - [project_export, 1] - - [project_web_hook, 1] + - [web_hook, 1] - [repository_check, 1] - - [system_hook, 1] - [git_garbage_collect, 1] - [reactive_caching, 1] - [cronjob, 1] diff --git a/config/webpack.config.js b/config/webpack.config.js index 42024739fe9..61f1eaaacd1 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -5,6 +5,7 @@ var path = require('path'); var webpack = require('webpack'); var StatsPlugin = require('stats-webpack-plugin'); var CompressionPlugin = require('compression-webpack-plugin'); +var NameAllModulesPlugin = require('name-all-modules-plugin'); var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; var WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin'); @@ -15,6 +16,7 @@ var DEV_SERVER_HOST = process.env.DEV_SERVER_HOST || 'localhost'; var DEV_SERVER_PORT = parseInt(process.env.DEV_SERVER_PORT, 10) || 3808; var DEV_SERVER_LIVERELOAD = process.env.DEV_SERVER_LIVERELOAD !== 'false'; var WEBPACK_REPORT = process.env.WEBPACK_REPORT; +var NO_COMPRESSION = process.env.NO_COMPRESSION; var config = { // because sqljs requires fs. @@ -23,6 +25,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', @@ -39,6 +42,7 @@ var config = { group: './group.js', groups_list: './groups_list.js', issue_show: './issue_show/index.js', + integrations: './integrations', locale: './locale/index.js', main: './main.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', @@ -47,8 +51,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', @@ -69,11 +72,10 @@ var config = { output: { path: path.join(ROOT_PATH, 'public/assets/webpack'), publicPath: '/assets/webpack/', - filename: IS_PRODUCTION ? '[name].[chunkhash].bundle.js' : '[name].bundle.js' + filename: IS_PRODUCTION ? '[name].[chunkhash].bundle.js' : '[name].bundle.js', + chunkFilename: IS_PRODUCTION ? '[name].[chunkhash].chunk.js' : '[name].chunk.js', }, - devtool: 'cheap-module-source-map', - module: { rules: [ { @@ -100,7 +102,7 @@ var config = { loader: 'file-loader', }, { - test: /locale\/[a-z]+\/(.*)\.js$/, + test: /locale\/\w+\/(.*)\.js$/, loader: 'exports-loader?locales', }, ] @@ -126,8 +128,20 @@ var config = { jQuery: 'jquery', }), - // use deterministic module ids + // assign deterministic module ids new webpack.NamedModulesPlugin(), + new NameAllModulesPlugin(), + + // assign deterministic chunk ids + new webpack.NamedChunksPlugin((chunk) => { + if (chunk.name) { + return chunk.name; + } + return chunk.modules.map((m) => { + var chunkPath = m.request.split('!').pop(); + return path.relative(m.context, chunkPath); + }).join('_'); + }), // create cacheable common library bundle for all vue chunks new webpack.optimize.CommonsChunkPlugin({ @@ -146,7 +160,7 @@ var config = { 'notebook_viewer', 'pdf_viewer', 'pipelines', - 'pipelines_graph', + 'pipelines_details', 'schedule_form', 'schedules_index', 'sidebar', @@ -208,11 +222,18 @@ if (IS_PRODUCTION) { }), new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify('production') } - }), - new CompressionPlugin({ - asset: '[path].gz[query]', }) ); + + // zopfli requires a lot of compute time and is disabled in CI + if (!NO_COMPRESSION) { + config.plugins.push( + new CompressionPlugin({ + asset: '[path].gz[query]', + algorithm: 'zopfli', + }) + ); + } } if (IS_DEV_SERVER) { diff --git a/db/fixtures/development/11_keys.rb b/db/fixtures/development/11_keys.rb index 51e22137d6f..c405ecfdaf3 100644 --- a/db/fixtures/development/11_keys.rb +++ b/db/fixtures/development/11_keys.rb @@ -1,17 +1,26 @@ require './spec/support/sidekiq' + # Creating keys runs a gitlab-shell worker. Since we may not have the right # gitlab-shell path set (yet) we need to disable this for these fixtures. Sidekiq::Testing.disable! do Gitlab::Seeder.quiet do + # We want to run `add_to_shell` immediately instead of after the commit, so + # that it falls under `Sidekiq::Testing.disable!`. + Key.skip_callback(:commit, :after, :add_to_shell) + User.first(10).each do |user| key = "ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt#{user.id + 100}6k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=" - user.keys.create( + key = user.keys.create( title: "Sample key #{user.id}", key: key ) + Sidekiq::Worker.skipping_transaction_check do + key.add_to_shell + end + print '.' end end diff --git a/db/fixtures/development/14_pipelines.rb b/db/fixtures/development/14_pipelines.rb index 3c42f7db6d5..5de5339b70e 100644 --- a/db/fixtures/development/14_pipelines.rb +++ b/db/fixtures/development/14_pipelines.rb @@ -98,7 +98,7 @@ class Gitlab::Seeder::Pipelines def create_pipeline!(project, ref, commit) - project.pipelines.create(sha: commit.id, ref: ref) + project.pipelines.create(sha: commit.id, ref: ref, source: :push) end def build_create!(pipeline, opts = {}) @@ -112,6 +112,10 @@ class Gitlab::Seeder::Pipelines setup_artifacts(build) setup_build_log(build) + + build.project.environments. + find_or_create_by(name: build.expanded_environment_name) + build.save end end diff --git a/db/fixtures/development/17_cycle_analytics.rb b/db/fixtures/development/17_cycle_analytics.rb index 0d7eb1a7c93..7c1d758dada 100644 --- a/db/fixtures/development/17_cycle_analytics.rb +++ b/db/fixtures/development/17_cycle_analytics.rb @@ -190,7 +190,7 @@ class Gitlab::Seeder::CycleAnalytics service = Ci::CreatePipelineService.new(merge_request.project, @user, ref: "refs/heads/#{merge_request.source_branch}") - pipeline = service.execute(ignore_skip_ci: true, save_on_errors: false) + pipeline = service.execute(:push, ignore_skip_ci: true, save_on_errors: false) pipeline.run! Timecop.travel rand(1..6).hours.from_now @@ -212,12 +212,9 @@ class Gitlab::Seeder::CycleAnalytics merge_requests.each do |merge_request| Timecop.travel 12.hours.from_now - CreateDeploymentService.new(merge_request.project, @user, { - environment: 'production', - ref: 'master', - tag: false, - sha: @project.repository.commit('master').sha - }).execute + job = merge_request.head_pipeline.builds.where.not(environment: nil).last + + CreateDeploymentService.new(job).execute end end end diff --git a/db/fixtures/development/21_conversational_development_index_metrics.rb b/db/fixtures/development/21_conversational_development_index_metrics.rb new file mode 100644 index 00000000000..4cd0a82ed1a --- /dev/null +++ b/db/fixtures/development/21_conversational_development_index_metrics.rb @@ -0,0 +1,40 @@ +Gitlab::Seeder.quiet do + conversational_development_index_metric = ConversationalDevelopmentIndex::Metric.new( + leader_issues: 10.2, + instance_issues: 3.2, + + leader_notes: 25.3, + instance_notes: 23.2, + + leader_milestones: 16.2, + instance_milestones: 5.5, + + leader_boards: 5.2, + instance_boards: 3.2, + + leader_merge_requests: 5.2, + instance_merge_requests: 3.2, + + leader_ci_pipelines: 25.1, + instance_ci_pipelines: 21.3, + + leader_environments: 3.3, + instance_environments: 2.2, + + leader_deployments: 41.3, + instance_deployments: 15.2, + + leader_projects_prometheus_active: 0.31, + instance_projects_prometheus_active: 0.30, + + leader_service_desk_issues: 15.8, + instance_service_desk_issues: 15.1 + ) + + if conversational_development_index_metric.save + print '.' + else + puts conversational_development_index_metric.errors.full_messages + print 'F' + end +end 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 bd0463886bc..4d6a61bd614 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 1eb99feb40c..b2a2ce41391 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 f1a1f001cb3..febd2c0e65e 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 66172bda6ff..2d2725ccf59 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 a576bb7b622..fe11699c196 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 50ad7437227..c7cada6dfc5 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/20170317203554_index_routes_path_for_like.rb b/db/migrate/20170317203554_index_routes_path_for_like.rb index 7ac09b2abe5..8d3609135d0 100644 --- a/db/migrate/20170317203554_index_routes_path_for_like.rb +++ b/db/migrate/20170317203554_index_routes_path_for_like.rb @@ -21,9 +21,8 @@ class IndexRoutesPathForLike < ActiveRecord::Migration def down return unless Gitlab::Database.postgresql? + return unless index_exists?(:routes, :path, name: INDEX_NAME) - if index_exists?(:routes, :path, name: INDEX_NAME) - execute("DROP INDEX CONCURRENTLY #{INDEX_NAME};") - end + remove_concurrent_index_by_name(:routes, INDEX_NAME) end end diff --git a/db/migrate/20170320173259_migrate_assignees.rb b/db/migrate/20170320173259_migrate_assignees.rb index 23e7500a32d..7b61e811317 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/20170402231018_remove_index_for_users_current_sign_in_at.rb b/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb index 9d4380ef960..84635fa39b9 100644 --- a/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb +++ b/db/migrate/20170402231018_remove_index_for_users_current_sign_in_at.rb @@ -11,13 +11,7 @@ class RemoveIndexForUsersCurrentSignInAt < ActiveRecord::Migration disable_ddl_transaction! def up - if index_exists? :users, :current_sign_in_at - if Gitlab::Database.postgresql? - execute 'DROP INDEX CONCURRENTLY index_users_on_current_sign_in_at;' - else - remove_concurrent_index :users, :current_sign_in_at - end - end + remove_concurrent_index :users, :current_sign_in_at end def down diff --git a/db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb b/db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb index 6116ca59ee4..1587eee06ae 100644 --- a/db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb +++ b/db/migrate/20170425112628_remove_foreigh_key_ci_trigger_schedules.rb @@ -4,10 +4,20 @@ class RemoveForeighKeyCiTriggerSchedules < ActiveRecord::Migration DOWNTIME = false def up - remove_foreign_key :ci_trigger_schedules, column: :trigger_id + if fk_on_trigger_schedules? + remove_foreign_key :ci_trigger_schedules, column: :trigger_id + end end def down # no op, the foreign key should not have been here end + + private + + # Not made more generic and lifted to the helpers as Rails 5 will provide + # such an API + def fk_on_trigger_schedules? + connection.foreign_keys(:ci_trigger_schedules).include?("ci_triggers") + end end diff --git a/db/migrate/20170427103502_create_web_hook_logs.rb b/db/migrate/20170427103502_create_web_hook_logs.rb new file mode 100644 index 00000000000..3643c52180c --- /dev/null +++ b/db/migrate/20170427103502_create_web_hook_logs.rb @@ -0,0 +1,22 @@ +# rubocop:disable all +class CreateWebHookLogs < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :web_hook_logs do |t| + t.references :web_hook, null: false, index: true, foreign_key: { on_delete: :cascade } + + t.string :trigger + t.string :url + t.text :request_headers + t.text :request_data + t.text :response_headers + t.text :response_body + t.string :response_status + t.float :execution_duration + t.string :internal_error_message + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20170503140201_reschedule_project_authorizations.rb b/db/migrate/20170503140201_reschedule_project_authorizations.rb new file mode 100644 index 00000000000..fa45adadbae --- /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 00000000000..c67690642c9 --- /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/20170503185032_index_redirect_routes_path_for_like.rb b/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb index 5b8b6c828be..8eb20faa03a 100644 --- a/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb +++ b/db/migrate/20170503185032_index_redirect_routes_path_for_like.rb @@ -21,9 +21,8 @@ class IndexRedirectRoutesPathForLike < ActiveRecord::Migration def down return unless Gitlab::Database.postgresql? + return unless index_exists?(:redirect_routes, :path, name: INDEX_NAME) - if index_exists?(:redirect_routes, :path, name: INDEX_NAME) - execute("DROP INDEX CONCURRENTLY #{INDEX_NAME};") - end + remove_concurrent_index_by_name(:redirect_routes, INDEX_NAME) 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 00000000000..62bf641daa6 --- /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/migrate/20170523121229_create_conversational_development_index_metrics.rb b/db/migrate/20170523121229_create_conversational_development_index_metrics.rb new file mode 100644 index 00000000000..9f9ec526055 --- /dev/null +++ b/db/migrate/20170523121229_create_conversational_development_index_metrics.rb @@ -0,0 +1,39 @@ +class CreateConversationalDevelopmentIndexMetrics < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :conversational_development_index_metrics do |t| + t.float :leader_issues, null: false + t.float :instance_issues, null: false + + t.float :leader_notes, null: false + t.float :instance_notes, null: false + + t.float :leader_milestones, null: false + t.float :instance_milestones, null: false + + t.float :leader_boards, null: false + t.float :instance_boards, null: false + + t.float :leader_merge_requests, null: false + t.float :instance_merge_requests, null: false + + t.float :leader_ci_pipelines, null: false + t.float :instance_ci_pipelines, null: false + + t.float :leader_environments, null: false + t.float :instance_environments, null: false + + t.float :leader_deployments, null: false + t.float :instance_deployments, null: false + + t.float :leader_projects_prometheus_active, null: false + t.float :instance_projects_prometheus_active, null: false + + t.float :leader_service_desk_issues, null: false + t.float :instance_service_desk_issues, null: false + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20170524125940_add_source_to_ci_pipeline.rb b/db/migrate/20170524125940_add_source_to_ci_pipeline.rb new file mode 100644 index 00000000000..1fa3d48037b --- /dev/null +++ b/db/migrate/20170524125940_add_source_to_ci_pipeline.rb @@ -0,0 +1,9 @@ +class AddSourceToCiPipeline < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_pipelines, :source, :integer + end +end diff --git a/db/migrate/20170524161101_add_protected_to_ci_variables.rb b/db/migrate/20170524161101_add_protected_to_ci_variables.rb new file mode 100644 index 00000000000..99d4861e889 --- /dev/null +++ b/db/migrate/20170524161101_add_protected_to_ci_variables.rb @@ -0,0 +1,15 @@ +class AddProtectedToCiVariables < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default(:ci_variables, :protected, :boolean, default: false) + end + + def down + remove_column(:ci_variables, :protected) + end +end diff --git a/db/migrate/20170525174156_create_feature_tables.rb b/db/migrate/20170525174156_create_feature_tables.rb new file mode 100644 index 00000000000..a083c89c85f --- /dev/null +++ b/db/migrate/20170525174156_create_feature_tables.rb @@ -0,0 +1,26 @@ +class CreateFeatureTables < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def self.up + create_table :features do |t| + t.string :key, null: false + t.timestamps null: false + end + add_index :features, :key, unique: true + + create_table :feature_gates do |t| + t.string :feature_key, null: false + t.string :key, null: false + t.string :value + t.timestamps null: false + end + add_index :feature_gates, [:feature_key, :key, :value], unique: true + end + + def self.down + drop_table :feature_gates + drop_table :features + 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 b518038e93a..82f8147547e 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 b61dd7cfc61..b1c9eed1148 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 a19b73fc114..3c13a3d2518 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 00000000000..1b44334395f --- /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/post_migrate/20170523083112_migrate_old_artifacts.rb b/db/post_migrate/20170523083112_migrate_old_artifacts.rb new file mode 100644 index 00000000000..f2690bd0017 --- /dev/null +++ b/db/post_migrate/20170523083112_migrate_old_artifacts.rb @@ -0,0 +1,72 @@ +class MigrateOldArtifacts < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + # This uses special heuristic to find potential candidates for data migration + # Read more about this here: https://gitlab.com/gitlab-org/gitlab-ce/issues/32036#note_30422345 + + def up + builds_with_artifacts.find_each do |build| + build.migrate_artifacts! + end + end + + def down + end + + private + + def builds_with_artifacts + Build.with_artifacts + .joins('JOIN projects ON projects.id = ci_builds.project_id') + .where('ci_builds.id < ?', min_id) + .where('projects.ci_id IS NOT NULL') + .select('id', 'created_at', 'project_id', 'projects.ci_id AS ci_id') + end + + def min_id + Build.joins('JOIN projects ON projects.id = ci_builds.project_id') + .where('projects.ci_id IS NULL') + .pluck('coalesce(min(ci_builds.id), 0)') + .first + end + + class Build < ActiveRecord::Base + self.table_name = 'ci_builds' + + scope :with_artifacts, -> { where.not(artifacts_file: [nil, '']) } + + def migrate_artifacts! + return unless File.exist?(source_artifacts_path) + return if File.exist?(target_artifacts_path) + + ensure_target_path + + FileUtils.move(source_artifacts_path, target_artifacts_path) + end + + private + + def source_artifacts_path + @source_artifacts_path ||= + File.join(Gitlab.config.artifacts.path, + created_at.utc.strftime('%Y_%m'), + ci_id.to_s, id.to_s) + end + + def target_artifacts_path + @target_artifacts_path ||= + File.join(Gitlab.config.artifacts.path, + created_at.utc.strftime('%Y_%m'), + project_id.to_s, id.to_s) + end + + def ensure_target_path + directory = File.dirname(target_artifacts_path) + FileUtils.mkdir_p(directory) unless Dir.exist?(directory) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 8d57159d2e0..47484e6b8fe 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -284,6 +284,7 @@ ActiveRecord::Schema.define(version: 20170526190948) do t.integer "lock_version" t.integer "auto_canceled_by_id" t.integer "pipeline_schedule_id" + t.integer "source" end add_index "ci_pipelines", ["auto_canceled_by_id"], name: "index_ci_pipelines_on_auto_canceled_by_id", using: :btree @@ -366,6 +367,7 @@ ActiveRecord::Schema.define(version: 20170526190948) do t.string "encrypted_value_salt" t.string "encrypted_value_iv" t.integer "project_id", null: false + t.boolean "protected", default: false, null: false end add_index "ci_variables", ["project_id"], name: "index_ci_variables_on_project_id", using: :btree @@ -380,6 +382,31 @@ ActiveRecord::Schema.define(version: 20170526190948) do add_index "container_repositories", ["project_id", "name"], name: "index_container_repositories_on_project_id_and_name", unique: true, using: :btree add_index "container_repositories", ["project_id"], name: "index_container_repositories_on_project_id", using: :btree + create_table "conversational_development_index_metrics", force: :cascade do |t| + t.float "leader_issues", null: false + t.float "instance_issues", null: false + t.float "leader_notes", null: false + t.float "instance_notes", null: false + t.float "leader_milestones", null: false + t.float "instance_milestones", null: false + t.float "leader_boards", null: false + t.float "instance_boards", null: false + t.float "leader_merge_requests", null: false + t.float "instance_merge_requests", null: false + t.float "leader_ci_pipelines", null: false + t.float "instance_ci_pipelines", null: false + t.float "leader_environments", null: false + t.float "instance_environments", null: false + t.float "leader_deployments", null: false + t.float "instance_deployments", null: false + t.float "leader_projects_prometheus_active", null: false + t.float "instance_projects_prometheus_active", null: false + t.float "leader_service_desk_issues", null: false + t.float "instance_service_desk_issues", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "deploy_keys_projects", force: :cascade do |t| t.integer "deploy_key_id", null: false t.integer "project_id", null: false @@ -451,6 +478,24 @@ ActiveRecord::Schema.define(version: 20170526190948) do add_index "events", ["target_id"], name: "index_events_on_target_id", using: :btree add_index "events", ["target_type"], name: "index_events_on_target_type", using: :btree + create_table "feature_gates", force: :cascade do |t| + t.string "feature_key", null: false + t.string "key", null: false + t.string "value" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "feature_gates", ["feature_key", "key", "value"], name: "index_feature_gates_on_feature_key_and_key_and_value", unique: true, using: :btree + + create_table "features", force: :cascade do |t| + t.string "key", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "features", ["key"], name: "index_features_on_key", unique: true, using: :btree + create_table "forked_project_links", force: :cascade do |t| t.integer "forked_to_project_id", null: false t.integer "forked_from_project_id", null: false @@ -939,6 +984,8 @@ ActiveRecord::Schema.define(version: 20170526190948) 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" @@ -1366,7 +1413,6 @@ ActiveRecord::Schema.define(version: 20170526190948) 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" @@ -1402,6 +1448,23 @@ ActiveRecord::Schema.define(version: 20170526190948) do add_index "users_star_projects", ["project_id"], name: "index_users_star_projects_on_project_id", using: :btree add_index "users_star_projects", ["user_id", "project_id"], name: "index_users_star_projects_on_user_id_and_project_id", unique: true, using: :btree + create_table "web_hook_logs", force: :cascade do |t| + t.integer "web_hook_id", null: false + t.string "trigger" + t.string "url" + t.text "request_headers" + t.text "request_data" + t.text "response_headers" + t.text "response_body" + t.string "response_status" + t.float "execution_duration" + t.string "internal_error_message" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "web_hook_logs", ["web_hook_id"], name: "index_web_hook_logs_on_web_hook_id", using: :btree + create_table "web_hooks", force: :cascade do |t| t.string "url", limit: 2000 t.integer "project_id" @@ -1419,8 +1482,8 @@ ActiveRecord::Schema.define(version: 20170526190948) do t.string "token" t.boolean "pipeline_events", default: false, null: false t.boolean "confidential_issues_events", default: false, null: false - t.boolean "job_events", default: false, null: false t.boolean "repository_update_events", default: false, null: false + t.boolean "job_events", default: false, null: false end add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree @@ -1467,4 +1530,5 @@ ActiveRecord::Schema.define(version: 20170526190948) do add_foreign_key "timelogs", "merge_requests", name: "fk_timelogs_merge_requests_merge_request_id", on_delete: :cascade add_foreign_key "trending_projects", "projects", on_delete: :cascade add_foreign_key "u2f_registrations", "users" + add_foreign_key "web_hook_logs", "web_hooks", on_delete: :cascade end diff --git a/doc/administration/gitaly/index.md b/doc/administration/gitaly/index.md index 6c6942a7bfe..48929910a9c 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/README.md b/doc/api/README.md index 1b0f6470b13..45579ccac4e 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -33,6 +33,7 @@ following locations: - [Notification settings](notification_settings.md) - [Pipelines](pipelines.md) - [Pipeline Triggers](pipeline_triggers.md) +- [Pipeline Schedules](pipeline_schedules.md) - [Projects](projects.md) including setting Webhooks - [Project Access Requests](access_requests.md) - [Project Members](members.md) diff --git a/doc/api/build_variables.md b/doc/api/build_variables.md index 2aaf1c93705..d4f00256ed3 100644 --- a/doc/api/build_variables.md +++ b/doc/api/build_variables.md @@ -61,11 +61,12 @@ Create a new build variable. POST /projects/:id/variables ``` -| Attribute | Type | required | Description | -|-----------|---------|----------|-----------------------| -| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `key` | string | yes | The `key` of a variable; must have no more than 255 characters; only `A-Z`, `a-z`, `0-9`, and `_` are allowed | -| `value` | string | yes | The `value` of a variable | +| Attribute | Type | required | Description | +|-------------|---------|----------|-----------------------| +| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `key` | string | yes | The `key` of a variable; must have no more than 255 characters; only `A-Z`, `a-z`, `0-9`, and `_` are allowed | +| `value` | string | yes | The `value` of a variable | +| `protected` | boolean | no | Whether the variable is protected | ``` curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/variables" --form "key=NEW_VARIABLE" --form "value=new value" @@ -74,7 +75,8 @@ curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitl ```json { "key": "NEW_VARIABLE", - "value": "new value" + "value": "new value", + "protected": false } ``` @@ -86,11 +88,12 @@ Update a project's build variable. PUT /projects/:id/variables/:key ``` -| Attribute | Type | required | Description | -|-----------|---------|----------|-------------------------| -| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user | -| `key` | string | yes | The `key` of a variable | -| `value` | string | yes | The `value` of a variable | +| Attribute | Type | required | Description | +|-------------|---------|----------|-------------------------| +| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `key` | string | yes | The `key` of a variable | +| `value` | string | yes | The `value` of a variable | +| `protected` | boolean | no | Whether the variable is protected | ``` curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/variables/NEW_VARIABLE" --form "value=updated value" @@ -99,7 +102,8 @@ curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitla ```json { "key": "NEW_VARIABLE", - "value": "updated value" + "value": "updated value", + "protected": true } ``` diff --git a/doc/api/features.md b/doc/api/features.md new file mode 100644 index 00000000000..89b8d3ac948 --- /dev/null +++ b/doc/api/features.md @@ -0,0 +1,83 @@ +# Features API + +All methods require administrator authorization. + +Notice that currently the API only supports boolean and percentage-of-time gate +values. + +## List all features + +Get a list of all persisted features, with its gate values. + +``` +GET /features +``` + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/features +``` + +Example response: + +```json +[ + { + "name": "experimental_feature", + "state": "off", + "gates": [ + { + "key": "boolean", + "value": false + } + ] + }, + { + "name": "new_library", + "state": "on", + "gates": [ + { + "key": "boolean", + "value": true + } + ] + } +] +``` + +## Set or create a feature + +Set a feature's gate value. If a feature with the given name doesn't exist yet +it will be created. The value can be a boolean, or an integer to indicate +percentage of time. + +``` +POST /features/:name +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `name` | string | yes | Name of the feature to create or update | +| `value` | integer/string | yes | `true` or `false` to enable/disable, or an integer for percentage of time | + +```bash +curl --data "value=30" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/features/new_library +``` + +Example response: + +```json +{ + "name": "new_library", + "state": "conditional", + "gates": [ + { + "key": "boolean", + "value": false + }, + { + "key": "percentage_of_time", + "value": 30 + } + ] +} +``` diff --git a/doc/api/pipeline_schedules.md b/doc/api/pipeline_schedules.md new file mode 100644 index 00000000000..433654c18cc --- /dev/null +++ b/doc/api/pipeline_schedules.md @@ -0,0 +1,273 @@ +# Pipeline schedules + +You can read more about [pipeline schedules](../user/project/pipelines/schedules.md). + +## Get all pipeline schedules + +Get a list of the pipeline schedules of a project. + +``` +GET /projects/:id/pipeline_schedules +``` + +| Attribute | Type | required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `scope` | string | no | The scope of pipeline schedules, one of: `active`, `inactive` | + +```sh +curl --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules" +``` + +```json +[ + { + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "* * * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T13:41:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:40:17.727Z", + "owner": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "https://gitlab.example.com/root" + } + } +] +``` + +## Get a single pipeline schedule + +Get the pipeline schedule of a project. + +``` +GET /projects/:id/pipeline_schedules/:pipeline_schedule_id +``` + +| Attribute | Type | required | Description | +|--------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | + +```sh +curl --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13" +``` + +```json +{ + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "* * * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T13:41:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:40:17.727Z", + "last_pipeline": { + "id": 332, + "sha": "0e788619d0b5ec17388dffb973ecd505946156db", + "ref": "master", + "status": "pending" + }, + "owner": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "https://gitlab.example.com/root" + } +} +``` + +## Create a new pipeline schedule + +Create a new pipeline schedule of a project. + +``` +POST /projects/:id/pipeline_schedules +``` + +| Attribute | Type | required | Description | +|---------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `description` | string | yes | The description of pipeline schedule | +| `ref` | string | yes | The branch/tag name will be triggered | +| `cron ` | string | yes | The cron (e.g. `0 1 * * *`) ([Cron syntax](https://en.wikipedia.org/wiki/Cron)) | +| `cron_timezone ` | string | no | The timezone supproted by `ActiveSupport::TimeZone` (e.g. `Pacific Time (US & Canada)`) (default: `'UTC'`) | +| `active ` | boolean | no | The activation of pipeline schedule. If false is set, the pipeline schedule will deactivated initially (default: `true`) | + +```sh +curl --request POST --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form description="Build packages" --form ref="master" --form cron="0 1 * * 5" --form cron_timezone="UTC" --form active="true" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules" +``` + +```json +{ + "id": 14, + "description": "Build packages", + "ref": "master", + "cron": "0 1 * * 5", + "cron_timezone": "UTC", + "next_run_at": "2017-05-26T01:00:00.000Z", + "active": true, + "created_at": "2017-05-19T13:43:08.169Z", + "updated_at": "2017-05-19T13:43:08.169Z", + "last_pipeline": null, + "owner": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "https://gitlab.example.com/root" + } +} +``` + +## Edit a pipeline schedule + +Updates the pipeline schedule of a project. Once the update is done, it will be rescheduled automatically. + +``` +PUT /projects/:id/pipeline_schedules/:pipeline_schedule_id +``` + +| Attribute | Type | required | Description | +|---------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | +| `description` | string | no | The description of pipeline schedule | +| `ref` | string | no | The branch/tag name will be triggered | +| `cron ` | string | no | The cron (e.g. `0 1 * * *`) ([Cron syntax](https://en.wikipedia.org/wiki/Cron)) | +| `cron_timezone ` | string | no | The timezone supproted by `ActiveSupport::TimeZone` (e.g. `Pacific Time (US & Canada)`) or `TZInfo::Timezone` (e.g. `America/Los_Angeles`) | +| `active ` | boolean | no | The activation of pipeline schedule. If false is set, the pipeline schedule will deactivated initially. | + +```sh +curl --request PUT --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" --form cron="0 2 * * *" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13" +``` + +```json +{ + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "0 2 * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T17:00:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:44:16.135Z", + "last_pipeline": { + "id": 332, + "sha": "0e788619d0b5ec17388dffb973ecd505946156db", + "ref": "master", + "status": "pending" + }, + "owner": { + "name": "Administrator", + "username": "root", + "id": 1, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "web_url": "https://gitlab.example.com/root" + } +} +``` + +## Take ownership of a pipeline schedule + +Update the owner of the pipeline schedule of a project. + +``` +POST /projects/:id/pipeline_schedules/:pipeline_schedule_id/take_ownership +``` + +| Attribute | Type | required | Description | +|---------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | + +```sh +curl --request POST --header "PRIVATE-TOKEN: hf2CvZXB9w8Uc5pZKpSB" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13/take_ownership" +``` + +```json +{ + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "0 2 * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T17:00:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:46:37.468Z", + "last_pipeline": { + "id": 332, + "sha": "0e788619d0b5ec17388dffb973ecd505946156db", + "ref": "master", + "status": "pending" + }, + "owner": { + "name": "shinya", + "username": "maeda", + "id": 50, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/8ca0a796a679c292e3a11da50f99e801?s=80&d=identicon", + "web_url": "https://gitlab.example.com/maeda" + } +} +``` + +## Delete a pipeline schedule + +Delete the pipeline schedule of a project. + +``` +DELETE /projects/:id/pipeline_schedules/:pipeline_schedule_id +``` + +| Attribute | Type | required | Description | +|----------------|---------|----------|--------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `pipeline_schedule_id` | integer | yes | The pipeline schedule id | + +```sh +curl --request DELETE --header "PRIVATE-TOKEN: k5ESFgWY2Qf5xEvDcFxZ" "https://gitlab.example.com/api/v4/projects/29/pipeline_schedules/13" +``` + +```json +{ + "id": 13, + "description": "Test schedule pipeline", + "ref": "master", + "cron": "0 2 * * *", + "cron_timezone": "Asia/Tokyo", + "next_run_at": "2017-05-19T17:00:00.000Z", + "active": true, + "created_at": "2017-05-19T13:31:08.849Z", + "updated_at": "2017-05-19T13:46:37.468Z", + "last_pipeline": { + "id": 332, + "sha": "0e788619d0b5ec17388dffb973ecd505946156db", + "ref": "master", + "status": "pending" + }, + "owner": { + "name": "shinya", + "username": "maeda", + "id": 50, + "state": "active", + "avatar_url": "http://www.gravatar.com/avatar/8ca0a796a679c292e3a11da50f99e801?s=80&d=identicon", + "web_url": "https://gitlab.example.com/maeda" + } +} +``` diff --git a/doc/api/projects.md b/doc/api/projects.md index 6b919f71792..70cad8a6025 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 [ @@ -79,6 +81,7 @@ Parameters: "kind": "group", "full_path": "diaspora" }, + "import_status": "none", "archived": false, "avatar_url": "http://example.com/uploads/project/avatar/4/uploads/avatar.png", "shared_runners_enabled": true, @@ -136,6 +139,8 @@ Parameters: "kind": "group", "full_path": "brightbox" }, + "import_status": "none", + "import_error": null, "permissions": { "project_access": { "access_level": 10, @@ -223,6 +228,8 @@ Parameters: "kind": "group", "full_path": "diaspora" }, + "import_status": "none", + "import_error": null, "permissions": { "project_access": { "access_level": 10, @@ -471,6 +478,7 @@ Parameters: | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | | `lfs_enabled` | boolean | no | Enable LFS | | `request_access_enabled` | boolean | no | Allow users to request member access | +| `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project | ### Create project for user @@ -504,6 +512,7 @@ Parameters: | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | | `lfs_enabled` | boolean | no | Enable LFS | | `request_access_enabled` | boolean | no | Allow users to request member access | +| `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project | ### Edit project @@ -536,6 +545,7 @@ Parameters: | `only_allow_merge_if_all_discussions_are_resolved` | boolean | no | Set whether merge requests can only be merged when all the discussions are resolved | | `lfs_enabled` | boolean | no | Enable LFS | | `request_access_enabled` | boolean | no | Allow users to request member access | +| `tag_list` | array | no | The list of tags for a project; put array of tags, that should be finally assigned to a project | ### Fork project @@ -606,6 +616,7 @@ Example response: "kind": "group", "full_path": "diaspora" }, + "import_status": "none", "archived": true, "avatar_url": "http://example.com/uploads/project/avatar/3/uploads/avatar.png", "shared_runners_enabled": true, @@ -671,6 +682,7 @@ Example response: "kind": "group", "full_path": "diaspora" }, + "import_status": "none", "archived": true, "avatar_url": "http://example.com/uploads/project/avatar/3/uploads/avatar.png", "shared_runners_enabled": true, @@ -742,6 +754,8 @@ Example response: "kind": "group", "full_path": "diaspora" }, + "import_status": "none", + "import_error": null, "permissions": { "project_access": { "access_level": 10, @@ -824,6 +838,8 @@ Example response: "kind": "group", "full_path": "diaspora" }, + "import_status": "none", + "import_error": null, "permissions": { "project_access": { "access_level": 10, diff --git a/doc/api/users.md b/doc/api/users.md index 331f9a9b80b..7e118dcf4a9 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -300,6 +300,9 @@ DELETE /users/:id Parameters: - `id` (required) - The ID of the user +- `hard_delete` (optional) - If true, contributions that would usually be + [moved to the ghost user](../user/profile/account/delete_account.md#associated-records) + will be deleted instead, as well as groups owned solely by this user. ## User diff --git a/doc/ci/environments.md b/doc/ci/environments.md index 169e0fbae3d..3393030210e 100644 --- a/doc/ci/environments.md +++ b/doc/ci/environments.md @@ -94,6 +94,12 @@ the name given in `.gitlab-ci.yml` (with any variables expanded), while the second is a "cleaned-up" version of the name, suitable for use in URLs, DNS, etc. +>**Note:** +Starting with GitLab 9.3, the environment URL is exposed to the Runner via +`$CI_ENVIRONMENT_URL`. The URL would be expanded from `.gitlab-ci.yml`, or if +the URL was not defined there, the external URL from the environment would be +used. + To sum up, with the above `.gitlab-ci.yml` we have achieved that: - All branches will run the `test` and `build` jobs. diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 0d4d08106f8..56ff245f9f9 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -10,7 +10,7 @@ The variables can be overwritten and they take precedence over each other in this order: 1. [Trigger variables][triggers] (take precedence over all) -1. [Secret variables](#secret-variables) +1. [Secret variables](#secret-variables) or [protected secret variables](#protected-secret-variables) 1. YAML-defined [job-level variables](../yaml/README.md#job-variables) 1. YAML-defined [global variables](../yaml/README.md#variables) 1. [Deployment variables](#deployment-variables) @@ -43,6 +43,7 @@ future GitLab releases.** | **CI_DEBUG_TRACE** | all | 1.7 | Whether [debug tracing](#debug-tracing) is enabled | | **CI_ENVIRONMENT_NAME** | 8.15 | all | The name of the environment for this job | | **CI_ENVIRONMENT_SLUG** | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. | +| **CI_ENVIRONMENT_URL** | 9.3 | all | The URL of the environment for this job | | **CI_JOB_ID** | 9.0 | all | The unique id of the current job that GitLab CI uses internally | | **CI_JOB_MANUAL** | 8.12 | all | The flag to indicate that job was manually started | | **CI_JOB_NAME** | 9.0 | 0.5 | The name of the job as defined in `.gitlab-ci.yml` | @@ -56,9 +57,10 @@ future GitLab releases.** | **CI_PIPELINE_TRIGGERED** | all | all | The flag to indicate that job was [triggered] | | **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the job is run | | **CI_PROJECT_ID** | all | all | The unique id of the current project that GitLab CI uses internally | -| **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built | +| **CI_PROJECT_NAME** | 8.10 | 0.5 | The project name that is currently being built (actually it is project folder name) | | **CI_PROJECT_NAMESPACE** | 8.10 | 0.5 | The project namespace (username or groupname) that is currently being built | | **CI_PROJECT_PATH** | 8.10 | 0.5 | The namespace with project name | +| **CI_PROJECT_PATH_SLUG** | 9.3 | all | `$CI_PROJECT_PATH` lowercased and with everything except `0-9` and `a-z` replaced with `-`. Use in URLs and domain names. | | **CI_PROJECT_URL** | 8.10 | 0.5 | The HTTP address to access project | | **CI_REGISTRY** | 8.10 | 0.5 | If the Container Registry is enabled it returns the address of GitLab's Container Registry | | **CI_REGISTRY_IMAGE** | 8.10 | 0.5 | If the Container Registry is enabled for the project it returns the address of the registry tied to the specific project | @@ -122,7 +124,7 @@ global defined variables in your job, define an empty array: ```yaml job_name: - variables: [] + variables: {} ``` You are able to use other variables inside your variable definition (or escape them with `$$`): @@ -153,9 +155,25 @@ storing things like passwords, secret keys and credentials. Secret variables can be added by going to your project's **Settings âž” Pipelines**, then finding the section called -**Secret Variables**. +**Secret variables**. -Once you set them, they will be available for all subsequent jobs. +Once you set them, they will be available for all subsequent pipelines. + +## Protected secret variables + +>**Notes:** +This feature requires GitLab 9.3 or higher. + +Secret variables could be protected. Whenever a secret variable is +protected, it would only be securely passed to pipelines running on the +[protected branches] or [protected tags]. The other pipelines would not get any +protected variables. + +Protected variables can be added by going to your project's +**Settings âž” Pipelines**, then finding the section called +**Secret variables**, and check *Protected*. + +Once you set them, they will be available for all subsequent pipelines. ## Deployment variables @@ -385,3 +403,5 @@ export CI_REGISTRY_PASSWORD="longalfanumstring" [runner]: https://docs.gitlab.com/runner/ [triggered]: ../triggers/README.md [triggers]: ../triggers/README.md#pass-job-variables-to-a-trigger +[protected branches]: ../../user/project/protected_branches.md +[protected tags]: ../../user/project/protected_tags.md diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index da20076da52..2c9aa437932 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -438,7 +438,7 @@ in your job, define an empty array: ```yaml job_name: - variables: [] + variables: {} ``` Job variables priority is defined in the [variables documentation][variables]. @@ -591,7 +591,7 @@ Optional manual actions have `allow_failure: true` set by default. **Manual actions are considered to be write actions, so permissions for protected branches are used when user wants to trigger an action. In other words, in order to trigger a manual action assigned to a branch that the -pipeline is running for, user needs to have ability to push to this branch.** +pipeline is running for, user needs to have ability to merge to this branch.** ### environment @@ -1105,6 +1105,36 @@ variables: GIT_STRATEGY: none ``` +## Git Checkout + +> Introduced in GitLab Runner 9.3 + +The `GIT_CHECKOUT` variable can be used when the `GIT_STRATEGY` is set to either +`clone` or `fetch` to specify whether a `git checkout` should be run. If not +specified, it defaults to true. Like `GIT_STRATEGY`, it can be set in either the +global [`variables`](#variables) section or the [`variables`](#job-variables) +section for individual jobs. + +If set to `false`, the Runner will: + +- when doing `fetch` - update the repository and leave working copy on + the current revision, +- when doing `clone` - clone the repository and leave working copy on the + default branch. + +Having this setting set to `true` will mean that for both `clone` and `fetch` +strategies the Runner will checkout the working copy to a revision related +to the CI pipeline: + +```yaml +variables: + GIT_STRATEGY: clone + GIT_CHECKOUT: false +script: + - git checkout master + - git merge $CI_BUILD_REF_NAME +``` + ## Git Submodule Strategy > Requires GitLab Runner v1.10+. diff --git a/doc/customization/libravatar.md b/doc/customization/libravatar.md index c46ce2ee203..9bd22d3966d 100644 --- a/doc/customization/libravatar.md +++ b/doc/customization/libravatar.md @@ -16,7 +16,7 @@ the configuration options as follows: ```yml gravatar: enabled: true - # gravatar URLs: possible placeholders: %{hash} %{size} %{email} + # gravatar URLs: possible placeholders: %{hash} %{size} %{email} %{username} plain_url: "http://cdn.libravatar.org/avatar/%{hash}?s=%{size}&d=identicon" ``` @@ -25,7 +25,7 @@ the configuration options as follows: ```yml gravatar: enabled: true - # gravatar URLs: possible placeholders: %{hash} %{size} %{email} + # gravatar URLs: possible placeholders: %{hash} %{size} %{email} %{username} ssl_url: "https://seccdn.libravatar.org/avatar/%{hash}?s=%{size}&d=identicon" ``` diff --git a/doc/development/README.md b/doc/development/README.md index 934c6849ff9..af4131c4a8f 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -42,6 +42,7 @@ - [Sidekiq debugging](sidekiq_debugging.md) - [Object state models](object_state_models.md) - [Building a package for testing purposes](build_test_package.md) +- [Manage feature flags](feature_flags.md) ## Databases @@ -49,6 +50,7 @@ - [Adding database indexes](adding_database_indexes.md) - [Post Deployment Migrations](post_deployment_migrations.md) - [Foreign Keys & Associations](foreign_keys.md) +- [Serializing Data](serializing_data.md) ## i18n diff --git a/doc/development/feature_flags.md b/doc/development/feature_flags.md new file mode 100644 index 00000000000..5c6316b9ac6 --- /dev/null +++ b/doc/development/feature_flags.md @@ -0,0 +1,7 @@ +# Manage feature flags + +Starting from GitLab 9.3 we support feature flags via +[Flipper](https://github.com/jnunemaker/flipper/). You should use the `Feature` +class (defined in `lib/feature.rb`) in your code to get, set and list feature +flags. During runtime you can set the values for the gates via the +[admin API](../api/features.md). diff --git a/doc/development/i18n_guide.md b/doc/development/i18n_guide.md index 44eca68aaca..bfb0779fbfa 100644 --- a/doc/development/i18n_guide.md +++ b/doc/development/i18n_guide.md @@ -7,6 +7,14 @@ For working with internationalization (i18n) we use tool for this task and we have a lot of applications that will help us to work with it. +## Setting up GitLab Development Kit (GDK) + +In order to be able to work on the [GitLab Community Edition](https://gitlab.com/gitlab-org/gitlab-ce) project we must download and +configure it through [GDK](https://gitlab.com/gitlab-org/gitlab-development-kit), we can do it by following this [guide](https://gitlab.com/gitlab-org/gitlab-development-kit/blob/master/doc/set-up-gdk.md). + +Once we have the GitLab project ready we can start working on the +translation of the project. + ## Tools We use a couple of gems: @@ -211,9 +219,11 @@ Let's suppose you want to add translations for a new language, let's say French. you just need to separate the region with an underscore (`_`). For example: ```sh - bundle exec rake gettext:add_language[en_gb] + bundle exec rake gettext:add_language[en_GB] ``` + Please note that you need to specify the region part in capitals. + 1. Now that the language is added, a new directory has been created under the path: `locale/fr/`. You can now start using your PO editor to edit the PO file located in: `locale/fr/gitlab.edit.po`. @@ -223,8 +233,7 @@ Let's suppose you want to add translations for a new language, let's say French. containing the translations: ```sh - bundle exec rake gettext:pack - bundle exec rake gettext:po_to_json + bundle exec rake gettext:compile ``` 1. In order to see the translated content we need to change our preferred language diff --git a/doc/development/serializing_data.md b/doc/development/serializing_data.md new file mode 100644 index 00000000000..2b56f48bc44 --- /dev/null +++ b/doc/development/serializing_data.md @@ -0,0 +1,84 @@ +# Serializing Data + +**Summary:** don't store serialized data in the database, use separate columns +and/or tables instead. + +Rails makes it possible to store serialized data in JSON, YAML or other formats. +Such a field can be defined as follows: + +```ruby +class Issue < ActiveRecord::Model + serialize :custom_fields +end +``` + +While it may be tempting to store serialized data in the database there are many +problems with this. This document will outline these problems and provide an +alternative. + +## Serialized Data Is Less Powerful + +When using a relational database you have the ability to query individual +fields, change the schema, index data and so forth. When you use serialized data +all of that becomes either very difficult or downright impossible. While +PostgreSQL does offer the ability to query JSON fields it is mostly meant for +very specialized use cases, and not for more general use. If you use YAML in +turn there's no way to query the data at all. + +## Waste Of Space + +Storing serialized data such as JSON or YAML will end up wasting a lot of space. +This is because these formats often include additional characters (e.g. double +quotes or newlines) besides the data that you are storing. + +## Difficult To Manage + +There comes a time where you will need to add a new field to the serialized +data, or change an existing one. Using serialized data this becomes difficult +and very time consuming as the only way of doing so is to re-write all the +stored values. To do so you would have to: + +1. Retrieve the data +1. Parse it into a Ruby structure +1. Mutate it +1. Serialize it back to a String +1. Store it in the database + +On the other hand, if one were to use regular columns adding a column would be +as easy as this: + +```sql +ALTER TABLE table_name ADD COLUMN column_name type; +``` + +Such a query would take very little to no time and would immediately apply to +all rows, without having to re-write large JSON or YAML structures. + +Finally, there comes a time when the JSON or YAML structure is no longer +sufficient and you need to migrate away from it. When storing only a few rows +this may not be a problem, but when storing millions of rows such a migration +can easily take hours or even days to complete. + +## Relational Databases Are Not Document Stores + +When storing data as JSON or YAML you're essentially using your database as if +it were a document store (e.g. MongoDB), except you're not using any of the +powerful features provided by a typical RDBMS _nor_ are you using any of the +features provided by a typical document store (e.g. the ability to index fields +of documents with variable fields). In other words, it's a waste. + +## Consistent Fields + +One argument sometimes made in favour of serialized data is having to store +widely varying fields and values. Sometimes this is truly the case, and then +perhaps it might make sense to use serialized data. However, in 99% of the cases +the fields and types stored tend to be the same for every row. Even if there is +a slight difference you can still use separate columns and just not set the ones +you don't need. + +## The Solution + +The solution is very simple: just use separate columns and/or separate tables. +This will allow you to use all the features provided by your database, it will +make it easier to manage and migrate the data, you'll conserve space, you can +index the data efficiently and so forth. diff --git a/doc/install/database_mysql.md b/doc/install/database_mysql.md index da2dac23c6a..9a171d34671 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 5bba405f159..84af6432889 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -143,20 +143,19 @@ Then install the Bundler Gem: ## 3. Go -Since GitLab 8.0, Git HTTP requests are handled by gitlab-workhorse (formerly -gitlab-git-http-server). This is a small daemon written in Go. To install -gitlab-workhorse we need a Go compiler. The instructions below assume you -use 64-bit Linux. You can find downloads for other platforms at the [Go download +Since GitLab 8.0, GitLab has several daemons written in Go. To install +GitLab we need a Go compiler. The instructions below assume you use 64-bit +Linux. You can find downloads for other platforms at the [Go download page](https://golang.org/dl). # Remove former Go installation folder sudo rm -rf /usr/local/go - curl --remote-name --progress https://storage.googleapis.com/golang/go1.5.3.linux-amd64.tar.gz - echo '43afe0c5017e502630b1aea4d44b8a7f059bf60d7f29dfd58db454d4e4e0ae53 go1.5.3.linux-amd64.tar.gz' | shasum -a256 -c - && \ - sudo tar -C /usr/local -xzf go1.5.3.linux-amd64.tar.gz + curl --remote-name --progress https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz + echo '1862f4c3d3907e59b04a757cfda0ea7aa9ef39274af99a784f5be843c80c6772 go1.8.3.linux-amd64.tar.gz' | shasum -a256 -c - && \ + sudo tar -C /usr/local -xzf go1.8.3.linux-amd64.tar.gz sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/ - rm go1.5.3.linux-amd64.tar.gz + rm go1.8.3.linux-amd64.tar.gz ## 4. Node @@ -166,7 +165,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 +184,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 +469,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 +486,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). @@ -518,6 +504,10 @@ Check if GitLab and its environment are configured correctly: sudo -u git -H yarn install --production --pure-lockfile sudo -u git -H bundle exec rake gitlab:assets:compile RAILS_ENV=production NODE_ENV=production +### Compile GetText PO files + + sudo -u git -H bundle exec rake gettext:compile RAILS_ENV=production + ### Start Your GitLab Instance sudo service gitlab start diff --git a/doc/install/kubernetes/gitlab_chart.md b/doc/install/kubernetes/gitlab_chart.md index 39ff4f8c1b8..b4ffd57afbb 100644 --- a/doc/install/kubernetes/gitlab_chart.md +++ b/doc/install/kubernetes/gitlab_chart.md @@ -206,9 +206,43 @@ its class in an annotation. >**Note:** The Ingress alone doesn't expose GitLab externally. You need to have a Ingress controller setup to do that. -Setting up an Ingress controller can be as simple as installing the `nginx-ingress` helm chart. But be sure +Setting up an Ingress controller can be done by installing the `nginx-ingress` helm chart. But be sure to read the [documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md) +#### Preserving Source IPs + +If you are using the `LoadBalancer` serviceType you may run into issues where user IP addresses in the GitLab +logs, and used in abuse throttling are not accurate. This is due to how Kubernetes uses source NATing on cluster nodes without endpoints. + +See the [Kubernetes documentation](https://kubernetes.io/docs/tutorials/services/source-ip/#source-ip-for-services-with-typeloadbalancer) for more information. + +To fix this you can add the following service annotation to your `values.yaml` + +```yaml +## For minikube, set this to NodePort, elsewhere use LoadBalancer +## ref: http://kubernetes.io/docs/user-guide/services/#publishing-services---service-types +## +serviceType: LoadBalancer + +## Optional annotations for gitlab service. +serviceAnnotations: + service.beta.kubernetes.io/external-traffic: "OnlyLocal" +``` + +>**Note:** +If you are using the ingress routing, you will likely also need to specify the annotation on the service for the ingress +controller. For `nginx-ingress` you can check the +[configuration documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md#configuration) +on how to add the annotation to the `controller.service.annotations` array. + +>**Note:** +When using the `nginx-ingress` controller on Google Container Engine (GKE), and using the `external-traffic` annotation, +you will need to additionally set the `controller.kind` to be DaemonSet. Otherwise only pods running on the same node +as the nginx controller will be able to reach GitLab. This may result in pods within your cluster not being able to reach GitLab. +See the [Kubernetes documentation](https://kubernetes.io/docs/tutorials/services/source-ip/#source-ip-for-services-with-typeloadbalancer) and +[nginx-ingress configuration documentation](https://github.com/kubernetes/charts/blob/master/stable/nginx-ingress/README.md#configuration) +for more information. + ### External database You can configure the GitLab Helm chart to connect to an external PostgreSQL diff --git a/doc/install/requirements.md b/doc/install/requirements.md index 2e456557d77..5338ccb9d3a 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/integration/saml.md b/doc/integration/saml.md index 2277aa827b7..b5b245c626f 100644 --- a/doc/integration/saml.md +++ b/doc/integration/saml.md @@ -201,6 +201,9 @@ Please keep in mind that every sign in attempt will be redirected to the SAML se so you will not be able to sign in using local credentials. Make sure that at least one of the SAML users has admin permissions. +You may also bypass the auto signin feature by browsing to +https://gitlab.example.com/users/sign_in?auto_sign_in=false. + ### `attribute_statements` >**Note:** diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md index 5be6053b76e..855f437cd73 100644 --- a/doc/raketasks/backup_restore.md +++ b/doc/raketasks/backup_restore.md @@ -133,7 +133,7 @@ It uses the [Fog library](http://fog.io/) to perform the upload. In the example below we use Amazon S3 for storage, but Fog also lets you use [other storage providers](http://fog.io/storage/). GitLab [imports cloud drivers](https://gitlab.com/gitlab-org/gitlab-ce/blob/30f5b9a5b711b46f1065baf755e413ceced5646b/Gemfile#L88) -for AWS, Google, OpenStack Swift and Rackspace as well. A local driver is +for AWS, Google, OpenStack Swift, Rackspace and Aliyun as well. A local driver is [also available](#uploading-to-locally-mounted-shares). For omnibus packages, add the following to `/etc/gitlab/gitlab.rb`: diff --git a/doc/university/README.md b/doc/university/README.md index c1661f0b52b..399d54bcf23 100644 --- a/doc/university/README.md +++ b/doc/university/README.md @@ -65,6 +65,7 @@ The curriculum is composed of GitLab videos, screencasts, presentations, project 1. [Using Innersourcing to Improve Collaboration](https://about.gitlab.com/2014/09/05/innersourcing-using-the-open-source-workflow-to-improve-collaboration-within-an-organization/) 1. [The Software Development Market and GitLab - Video](https://www.youtube.com/watch?v=sXlhgPK1NTY&list=PLFGfElNsQthbQu_IWlNOxul0TbS_2JH-e&index=6) - [Slides](https://docs.google.com/presentation/d/1vCU-NbZWz8NTNK8Vu3y4zGMAHb5DpC8PE5mHtw1PWfI/edit) 1. [The GitLab Book Club](bookclub/index.md) +1. [GitLab Resources](https://about.gitlab.com/resources/) #### 1.7 Community and Support 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 00000000000..0c32e4db53f --- /dev/null +++ b/doc/update/9.2-to-9.3.md @@ -0,0 +1,305 @@ +# 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. Update Go + +NOTE: GitLab 9.3 and higher only supports Go 1.8.3 and dropped support for Go 1.5.x through 1.7.x. Be +sure to upgrade your installation if necessary + +You can check which version you are running with `go version`. + +Download and install Go: + +```bash +# Remove former Go installation folder +sudo rm -rf /usr/local/go + +curl --remote-name --progress https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz +echo '1862f4c3d3907e59b04a757cfda0ea7aa9ef39274af99a784f5be843c80c6772 go1.8.3.linux-amd64.tar.gz' | shasum -a256 -c - && \ + sudo tar -C /usr/local -xzf go1.8.3.linux-amd64.tar.gz +sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/ +rm go1.8.3.linux-amd64.tar.gz +``` + +### 6. 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 +``` + +### 5. 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 +``` + +### 6. 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 +``` + +### 7. 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 +``` + +### 10. 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 +``` + +### 11. 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). + +### 12. Start application + +```bash +sudo service gitlab start +sudo service nginx restart +``` + +### 13. 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 151c17f3bf1..c4921c74a17 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 @@ -71,7 +80,7 @@ structure. - You need to be an Owner of a group in order to be able to create a subgroup. For more information check the [permissions table][permissions]. - For a list of words that are not allowed to be used as group names see the - [`regex.rb` file][reserved] under the `TOP_LEVEL_ROUTES`, `PROJECT_WILDCARD_ROUTES` and `GROUP_ROUTES` lists: + [`path_regex.rb` file][reserved] under the `TOP_LEVEL_ROUTES`, `PROJECT_WILDCARD_ROUTES` and `GROUP_ROUTES` lists: - `TOP_LEVEL_ROUTES`: are names that are reserved as usernames or top level groups - `PROJECT_WILDCARD_ROUTES`: are names that are reserved for child groups or projects. - `GROUP_ROUTES`: are names that are reserved for all groups or projects. @@ -163,4 +172,4 @@ Here's a list of what you can't do with subgroups: [ce-2772]: https://gitlab.com/gitlab-org/gitlab-ce/issues/2772 [permissions]: ../../permissions.md#group -[reserved]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/gitlab/regex.rb +[reserved]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/gitlab/path_regex.rb diff --git a/doc/user/profile/account/delete_account.md b/doc/user/profile/account/delete_account.md index b5d3b009044..6e274a152e5 100644 --- a/doc/user/profile/account/delete_account.md +++ b/doc/user/profile/account/delete_account.md @@ -5,21 +5,30 @@ ## Associated Records -> Introduced for issues in [GitLab 9.0][ce-7393], and for merge requests, award emoji, notes, and abuse reports in [GitLab 9.1][ce-10467]. +> Introduced for issues in [GitLab 9.0][ce-7393], and for merge requests, award + emoji, notes, and abuse reports in [GitLab 9.1][ce-10467]. + Hard deletion from abuse reports and spam logs was introduced in + [GitLab 9.1][ce-10273], and from the API in [GitLab 9.3][ce-11853]. -When a user account is deleted, not all associated records are deleted with it. Here's a list of things that will not be deleted: +When a user account is deleted, not all associated records are deleted with it. +Here's a list of things that will not be deleted: - Issues that the user created - Merge requests that the user created - Notes that the user created - Abuse reports that the user reported -- Award emoji that the user craeted +- Award emoji that the user created +Instead of being deleted, these records will be moved to a system-wide +"Ghost User", whose sole purpose is to act as a container for such records. -Instead of being deleted, these records will be moved to a system-wide "Ghost User", whose sole purpose is to act as a container for such records. - +When a user is deleted from an abuse report or spam log, these associated +records are not ghosted and will be removed, along with any groups the user +is a sole owner of. Administrators can also request this behaviour when +deleting users from the [API](../../../api/users.md#user-deletion) [ce-7393]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7393 +[ce-10273]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10273 [ce-10467]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10467 - +[ce-11853]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/11853 diff --git a/doc/user/project/container_registry.md b/doc/user/project/container_registry.md index 6a2ca7fb428..3cbb0b5196d 100644 --- a/doc/user/project/container_registry.md +++ b/doc/user/project/container_registry.md @@ -95,8 +95,6 @@ and click **Registry** in the project menu. This view will show you all tags in your project and will easily allow you to delete them. -![Container Registry panel](img/container_registry_panel.png) - ## Build and push images using GitLab CI > **Note:** diff --git a/doc/user/project/img/container_registry_panel.png b/doc/user/project/img/container_registry_panel.png Binary files differdeleted file mode 100644 index e4c9ecbb25b..00000000000 --- a/doc/user/project/img/container_registry_panel.png +++ /dev/null diff --git a/doc/user/project/integrations/img/webhook_logs.png b/doc/user/project/integrations/img/webhook_logs.png Binary files differnew file mode 100755 index 00000000000..917068d9398 --- /dev/null +++ b/doc/user/project/integrations/img/webhook_logs.png diff --git a/doc/user/project/integrations/jira.md b/doc/user/project/integrations/jira.md index f611029afdc..a048260b033 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/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index 48d49c5d40c..d0bb1cd11a8 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -1017,6 +1017,22 @@ X-Gitlab-Event: Build Hook } ``` +## Troubleshoot webhooks + +Gitlab stores each perform of the webhook. +You can find records for last 2 days in "Recent Deliveries" section on the edit page of each webhook. + +![Recent deliveries](img/webhook_logs.png) + +In this section you can see HTTP status code (green for 200-299 codes, red for the others, `internal error` for failed deliveries ), triggered event, a time when the event was called, elapsed time of the request. + +If you need more information about execution, you can click `View details` link. +On this page, you can see data that GitLab sends (request headers and body) and data that it received (response headers and body). + +From this page, you can repeat delivery with the same data by clicking `Resend Request` button. + +>**Note:** If URL or secret token of the webhook were updated, data will be delivered to the new address. + ## Example webhook receiver If you want to see GitLab's webhooks in action for testing purposes you can use diff --git a/doc/user/project/milestones/img/progress.png b/doc/user/project/milestones/img/progress.png Binary files differnew file mode 100644 index 00000000000..c85aecca729 --- /dev/null +++ b/doc/user/project/milestones/img/progress.png diff --git a/doc/user/project/milestones/index.md b/doc/user/project/milestones/index.md index a43a42a8fe8..99233ed5ae2 100644 --- a/doc/user/project/milestones/index.md +++ b/doc/user/project/milestones/index.md @@ -44,3 +44,11 @@ special options available when filtering by milestone: * **Started** - show issues or merge requests from any milestone with a start date less than today. Note that this can return results from several milestones in the same project. + +## Milestone progress statistics + +Milestone statistics can be viewed in the milestone sidebar. The milestone percentage statistic +is calculated as; closed and merged merge requests plus all closed issues divided by +total merge requests and issues. + +![Milestone statistics](img/progress.png) diff --git a/doc/user/project/pipelines/schedules.md b/doc/user/project/pipelines/schedules.md index 641876f948f..d19d184f9b0 100644 --- a/doc/user/project/pipelines/schedules.md +++ b/doc/user/project/pipelines/schedules.md @@ -53,7 +53,7 @@ Sidekiq, which runs according to its interval. For example, if you set a schedule to create a pipeline every minute (`* * * * *`) and the Sidekiq worker runs on 00:00 and 12:00 every day (`0 */12 * * *`), only 2 pipelines will be created per day. To change the Sidekiq worker's frequency, you have to edit the -`trigger_schedule_worker_cron` value in your `gitlab.rb` and restart GitLab. +`pipeline_schedule_worker_cron` value in your `gitlab.rb` and restart GitLab. For GitLab.com, you can check the [dedicated settings page][settings]. If you don't have admin access to the server, ask your administrator. diff --git a/doc/workflow/gitlab_flow.md b/doc/workflow/gitlab_flow.md index 1b172b21f3d..e10ccc4fc46 100644 --- a/doc/workflow/gitlab_flow.md +++ b/doc/workflow/gitlab_flow.md @@ -67,7 +67,7 @@ With GitLab flow we offer additional guidance for these questions. ![Master branch and production branch with arrow that indicate deployments](production_branch.png) GitHub flow does assume you are able to deploy to production every time you merge a feature branch. -This is possible for SaaS applications but there are many cases where this is not possible. +This is possible for e.g. SaaS applications, but there are many cases where this is not possible. One would be a situation where you are not in control of the exact release moment, for example an iOS application that needs to pass App Store validation. Another example is when you have deployment windows (workdays from 10am to 4pm when the operations team is at full capacity) but you also merge code at other times. In these cases you can make a production branch that reflects the deployed code. @@ -134,7 +134,7 @@ If the assigned person does not feel comfortable they can close the merge reques In GitLab it is common to protect the long-lived branches (e.g. the master branch) so that normal developers [can't modify these protected branches](http://docs.gitlab.com/ce/permissions/permissions.html). So if you want to merge it into a protected branch you assign it to someone with master authorizations. -## Issues with GitLab flow +## Issue tracking with GitLab flow ![Merge request with the branch name 15-require-a-password-to-change-it and assignee field shown](merge_request.png) @@ -173,9 +173,9 @@ It is possible that one feature branch solves more than one issue. ![Merge request showing the linked issues that will be closed](close_issue_mr.png) -Linking to the issue can happen by mentioning them from commit messages (fixes #14, closes #67, etc.) or from the merge request description. -In GitLab this creates a comment in the issue that the merge requests mentions the issue. -And the merge request shows the linked issues. +Linking to issues can happen by mentioning them in commit messages (fixes #14, closes #67, etc.) or in the merge request description. +GitLab then creates links to the mentioned issues and creates comments in the corresponding issues linking back to the merge request. + These issues are closed once code is merged into the default branch. If you only want to make the reference without closing the issue you can also just mention it: "Duck typing is preferred. #12". @@ -300,7 +300,7 @@ If there are no merge conflicts and the feature branches are short lived the ris If there are merge conflicts you merge the master branch into the feature branch and the CI server will rerun the tests. If you have long lived feature branches that last for more than a few days you should make your issues smaller. -## Merging in other code +## Working wih feature branches ![Shell output showing git pull output](git_pull.png) diff --git a/features/dashboard/starred_projects.feature b/features/dashboard/starred_projects.feature deleted file mode 100644 index 9dfd2fbab9c..00000000000 --- 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/hooks.feature b/features/project/hooks.feature deleted file mode 100644 index 627738004c4..00000000000 --- a/features/project/hooks.feature +++ /dev/null @@ -1,37 +0,0 @@ -Feature: Project Hooks - Background: - Given I sign in as a user - And I own project "Shop" - - Scenario: I should see hook list - Given project has hook - When I visit project hooks page - Then I should see project hook - - Scenario: I add new hook - Given I visit project hooks page - When I submit new hook - Then I should see newly created hook - - Scenario: I add new hook with SSL verification enabled - Given I visit project hooks page - When I submit new hook with SSL verification enabled - Then I should see newly created hook with SSL verification enabled - - Scenario: I test hook - Given project has hook - And I visit project hooks page - When I click test hook button - Then hook should be triggered - - Scenario: I test a hook on empty project - Given I own empty project with hook - And I visit project hooks page - When I click test hook button - Then I should see hook error message - - Scenario: I test a hook on down URL - Given project has hook - And I visit project hooks page - When I click test hook button with invalid URL - Then I should see hook service down error message diff --git a/features/project/merge_requests/accept.feature b/features/project/merge_requests/accept.feature index c45ed9ea68b..2ab1c19f452 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/project/service.feature b/features/project/service.feature index cce5f58adec..54f07ebca92 100644 --- a/features/project/service.feature +++ b/features/project/service.feature @@ -11,77 +11,77 @@ Feature: Project Services When I visit project "Shop" services page And I click hipchat service link And I fill hipchat settings - Then I should see hipchat service settings saved + Then I should see the Hipchat success message Scenario: Activate hipchat service with custom server When I visit project "Shop" services page And I click hipchat service link And I fill hipchat settings with custom server - Then I should see hipchat service settings with custom server saved + Then I should see the Hipchat success message Scenario: Activate pivotaltracker service When I visit project "Shop" services page And I click pivotaltracker service link And I fill pivotaltracker settings - Then I should see pivotaltracker service settings saved + Then I should see the Pivotaltracker success message Scenario: Activate Flowdock service When I visit project "Shop" services page And I click Flowdock service link And I fill Flowdock settings - Then I should see Flowdock service settings saved + Then I should see the Flowdock success message Scenario: Activate Assembla service When I visit project "Shop" services page And I click Assembla service link And I fill Assembla settings - Then I should see Assembla service settings saved + Then I should see the Assembla success message Scenario: Activate Slack notifications service When I visit project "Shop" services page And I click Slack notifications service link And I fill Slack notifications settings - Then I should see Slack Notifications service settings saved + Then I should see the Slack notifications success message Scenario: Activate Pushover service When I visit project "Shop" services page And I click Pushover service link And I fill Pushover settings - Then I should see Pushover service settings saved + Then I should see the Pushover success message Scenario: Activate email on push service When I visit project "Shop" services page And I click email on push service link And I fill email on push settings - Then I should see email on push service settings saved + Then I should see the Emails on push success message Scenario: Activate JIRA service When I visit project "Shop" services page And I click jira service link And I fill jira settings - Then I should see jira service settings saved + Then I should see the JIRA success message Scenario: Activate Irker (IRC Gateway) service When I visit project "Shop" services page And I click Irker service link And I fill Irker settings - Then I should see Irker service settings saved + Then I should see the Irker success message Scenario: Activate Atlassian Bamboo CI service When I visit project "Shop" services page And I click Atlassian Bamboo CI service link And I fill Atlassian Bamboo CI settings - Then I should see Atlassian Bamboo CI service settings saved + Then I should see the Bamboo success message And I should see empty field Change Password Scenario: Activate jetBrains TeamCity CI service When I visit project "Shop" services page And I click jetBrains TeamCity CI service link And I fill jetBrains TeamCity CI settings - Then I should see jetBrains TeamCity CI service settings saved + Then I should see the JetBrains success message Scenario: Activate Asana service When I visit project "Shop" services page And I click Asana service link And I fill Asana settings - Then I should see Asana service settings saved + Then I should see the Asana success message diff --git a/features/steps/dashboard/dashboard.rb b/features/steps/dashboard/dashboard.rb index bf09d7b7114..71c69a4fdea 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 b2194275751..1a55f40abb9 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/hooks.rb b/features/steps/project/hooks.rb deleted file mode 100644 index 945d58a6458..00000000000 --- a/features/steps/project/hooks.rb +++ /dev/null @@ -1,75 +0,0 @@ -require 'webmock' - -class Spinach::Features::ProjectHooks < Spinach::FeatureSteps - include SharedAuthentication - include SharedProject - include SharedPaths - include RSpec::Matchers - include RSpec::Mocks::ExampleMethods - include WebMock::API - - step 'project has hook' do - @hook = create(:project_hook, project: current_project) - end - - step 'I own empty project with hook' do - @project = create(:empty_project, - name: 'Empty Project', namespace: @user.namespace) - @hook = create(:project_hook, project: current_project) - end - - step 'I should see project hook' do - expect(page).to have_content @hook.url - end - - step 'I submit new hook' do - @url = 'http://example.org/1' - fill_in "hook_url", with: @url - expect { click_button "Add webhook" }.to change(ProjectHook, :count).by(1) - end - - step 'I submit new hook with SSL verification enabled' do - @url = 'http://example.org/2' - fill_in "hook_url", with: @url - check "hook_enable_ssl_verification" - expect { click_button "Add webhook" }.to change(ProjectHook, :count).by(1) - end - - step 'I should see newly created hook' do - expect(current_path).to eq namespace_project_settings_integrations_path(current_project.namespace, current_project) - expect(page).to have_content(@url) - end - - step 'I should see newly created hook with SSL verification enabled' do - expect(current_path).to eq namespace_project_settings_integrations_path(current_project.namespace, current_project) - expect(page).to have_content(@url) - expect(page).to have_content("SSL Verification: enabled") - end - - step 'I click test hook button' do - stub_request(:post, @hook.url).to_return(status: 200) - click_link 'Test' - end - - step 'I click test hook button with invalid URL' do - stub_request(:post, @hook.url).to_raise(SocketError) - click_link 'Test' - end - - step 'hook should be triggered' do - expect(current_path).to eq namespace_project_settings_integrations_path(current_project.namespace, current_project) - expect(page).to have_selector '.flash-notice', - text: 'Hook executed successfully: HTTP 200' - end - - step 'I should see hook error message' do - expect(page).to have_selector '.flash-alert', - text: 'Hook execution failed. '\ - 'Ensure the project has commits.' - end - - step 'I should see hook service down error message' do - expect(page).to have_selector '.flash-alert', - text: 'Hook execution failed: Exception from' - end -end diff --git a/features/steps/project/merge_requests/acceptance.rb b/features/steps/project/merge_requests/acceptance.rb index 023f9bef8e5..870dc862992 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/pages.rb b/features/steps/project/pages.rb index fea82d9fb57..4e6830f738b 100644 --- a/features/steps/project/pages.rb +++ b/features/steps/project/pages.rb @@ -35,7 +35,7 @@ class Spinach::Features::ProjectPages < Spinach::FeatureSteps end step 'pages are deployed' do - pipeline = @project.ensure_pipeline('HEAD', @project.commit('HEAD').sha) + pipeline = @project.pipelines.create(ref: 'HEAD', sha: @project.commit('HEAD').sha) build = build(:ci_build, project: @project, pipeline: pipeline, diff --git a/features/steps/project/services.rb b/features/steps/project/services.rb index 3c0d987e403..6bac4df16f8 100644 --- a/features/steps/project/services.rb +++ b/features/steps/project/services.rb @@ -34,8 +34,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see hipchat service settings saved' do - expect(find_field('Room').value).to eq 'gitlab' + step 'I should see the Hipchat success message' do + expect(page).to have_content 'HipChat activated.' end step 'I fill hipchat settings with custom server' do @@ -46,10 +46,6 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see hipchat service settings with custom server saved' do - expect(find_field('Server').value).to eq 'https://chat.example.com' - end - step 'I click pivotaltracker service link' do click_link 'PivotalTracker' end @@ -60,8 +56,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see pivotaltracker service settings saved' do - expect(find_field('Token').value).to eq 'verySecret' + step 'I should see the Pivotaltracker success message' do + expect(page).to have_content 'PivotalTracker activated.' end step 'I click Flowdock service link' do @@ -74,8 +70,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see Flowdock service settings saved' do - expect(find_field('Token').value).to eq 'verySecret' + step 'I should see the Flowdock success message' do + expect(page).to have_content 'Flowdock activated.' end step 'I click Assembla service link' do @@ -88,8 +84,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see Assembla service settings saved' do - expect(find_field('Token').value).to eq 'verySecret' + step 'I should see the Assembla success message' do + expect(page).to have_content 'Assembla activated.' end step 'I click Asana service link' do @@ -103,9 +99,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see Asana service settings saved' do - expect(find_field('Api key').value).to eq 'verySecret' - expect(find_field('Restrict to branch').value).to eq 'master' + step 'I should see the Asana success message' do + expect(page).to have_content 'Asana activated.' end step 'I click email on push service link' do @@ -113,12 +108,13 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps end step 'I fill email on push settings' do + check 'Active' fill_in 'Recipients', with: 'qa@company.name' click_button 'Save' end - step 'I should see email on push service settings saved' do - expect(find_field('Recipients').value).to eq 'qa@company.name' + step 'I should see the Emails on push success message' do + expect(page).to have_content 'Emails on push activated.' end step 'I click Irker service link' do @@ -132,9 +128,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see Irker service settings saved' do - expect(find_field('Recipients').value).to eq 'irc://chat.freenode.net/#commits' - expect(find_field('Colorize messages').value).to eq '1' + step 'I should see the Irker success message' do + expect(page).to have_content 'Irker (IRC gateway) activated.' end step 'I click Slack notifications service link' do @@ -147,8 +142,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see Slack Notifications service settings saved' do - expect(find_field('Webhook').value).to eq 'https://hooks.slack.com/services/SVRWFV0VVAR97N/B02R25XN3/ZBqu7xMupaEEICInN685' + step 'I should see the Slack notifications success message' do + expect(page).to have_content 'Slack notifications activated.' end step 'I click Pushover service link' do @@ -165,12 +160,8 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see Pushover service settings saved' do - expect(find_field('Api key').value).to eq 'verySecret' - expect(find_field('User key').value).to eq 'verySecret' - expect(find_field('Device').value).to eq 'myDevice' - expect(find_field('Priority').find('option[selected]').value).to eq '1' - expect(find_field('Sound').find('option[selected]').value).to eq 'bike' + step 'I should see the Pushover success message' do + expect(page).to have_content 'Pushover activated.' end step 'I click jira service link' do @@ -178,17 +169,18 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps end step 'I fill jira settings' do - fill_in 'URL', with: 'http://jira.example' + check 'Active' + + 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' click_button 'Save' end - step 'I should see jira service settings saved' do - expect(find_field('URL').value).to eq 'http://jira.example' - expect(find_field('Username').value).to eq 'gitlab' - expect(find_field('Project Key').value).to eq 'GITLAB' + step 'I should see the JIRA success message' do + expect(page).to have_content 'JIRA activated.' end step 'I click Atlassian Bamboo CI service link' do @@ -204,13 +196,13 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see Atlassian Bamboo CI service settings saved' do - expect(find_field('Bamboo url').value).to eq 'http://bamboo.example.com' - expect(find_field('Build key').value).to eq 'KEY' - expect(find_field('Username').value).to eq 'user' + step 'I should see the Bamboo success message' do + expect(page).to have_content 'Atlassian Bamboo CI activated.' end step 'I should see empty field Change Password' do + click_link 'Atlassian Bamboo CI' + expect(find_field('Enter new password').value).to be_nil end @@ -227,9 +219,7 @@ class Spinach::Features::ProjectServices < Spinach::FeatureSteps click_button 'Save' end - step 'I should see JetBrains TeamCity CI service settings saved' do - expect(find_field('Teamcity url').value).to eq 'http://teamcity.example.com' - expect(find_field('Build type').value).to eq 'GitlabTest_Build' - expect(find_field('Username').value).to eq 'user' + step 'I should see the JetBrains success message' do + expect(page).to have_content 'JetBrains TeamCity CI activated.' end end diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index 6efd4374b32..d099d7af167 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -372,6 +372,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps expect(page).to have_content 'Permalink' expect(page).not_to have_content 'Edit' expect(page).not_to have_content 'Blame' + expect(page).not_to have_content 'Annotate' expect(page).to have_content 'Delete' expect(page).to have_content 'Replace' end diff --git a/features/steps/shared/builds.rb b/features/steps/shared/builds.rb index 5549fc25525..624f1a7858b 100644 --- a/features/steps/shared/builds.rb +++ b/features/steps/shared/builds.rb @@ -27,11 +27,11 @@ module SharedBuilds end step 'I visit recent build details page' do - visit namespace_project_build_path(@project.namespace, @project, @build) + visit namespace_project_job_path(@project.namespace, @project, @build) end step 'I visit project builds page' do - visit namespace_project_builds_path(@project.namespace, @project) + visit namespace_project_jobs_path(@project.namespace, @project) end step 'recent build has artifacts available' do @@ -56,7 +56,7 @@ module SharedBuilds end step 'I access artifacts download page' do - visit download_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + visit download_namespace_project_job_artifacts_path(@project.namespace, @project, @build) end step 'I see details of a build' do diff --git a/features/steps/shared/markdown.rb b/features/steps/shared/markdown.rb index 6610b97ecb2..c2bec2a6320 100644 --- a/features/steps/shared/markdown.rb +++ b/features/steps/shared/markdown.rb @@ -30,7 +30,7 @@ module SharedMarkdown end step 'I should see the Markdown write tab' do - expect(find('.gfm-form')).to have_css('.js-md-write-button', visible: true) + expect(first('.gfm-form')).to have_link('Write', visible: true) end step 'I should see the Markdown preview' do @@ -49,9 +49,9 @@ module SharedMarkdown end step 'I preview a description text like "Bug fixed :smile:"' do - page.within('.gfm-form') do + page.within(first('.gfm-form')) do fill_in 'Description', with: 'Bug fixed :smile:' - find('.js-md-preview-button').click + click_link 'Preview' end end diff --git a/lib/api/api.rb b/lib/api/api.rb index 52cd7cbe3db..7ae2f3cad40 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -45,9 +45,9 @@ module API end before { allow_access_with_scope :api } - before { Gitlab::I18n.set_locale(current_user) } + before { Gitlab::I18n.locale = current_user&.preferred_language } - after { Gitlab::I18n.reset_locale } + after { Gitlab::I18n.use_default_locale } rescue_from Gitlab::Access::AccessDeniedError do rack_response({ 'message' => '403 Forbidden' }.to_json, 403) @@ -94,6 +94,7 @@ module API mount ::API::DeployKeys mount ::API::Deployments mount ::API::Environments + mount ::API::Features mount ::API::Files mount ::API::Groups mount ::API::Internal @@ -110,6 +111,7 @@ module API mount ::API::Notes mount ::API::NotificationSettings mount ::API::Pipelines + mount ::API::PipelineSchedules mount ::API::ProjectHooks mount ::API::Projects mount ::API::ProjectSnippets diff --git a/lib/api/commit_statuses.rb b/lib/api/commit_statuses.rb index 827a38d33da..10f2d5ef6a3 100644 --- a/lib/api/commit_statuses.rb +++ b/lib/api/commit_statuses.rb @@ -68,7 +68,14 @@ module API name = params[:name] || params[:context] || 'default' - pipeline = @project.ensure_pipeline(ref, commit.sha, current_user) + pipeline = @project.pipeline_for(ref, commit.sha) + unless pipeline + pipeline = @project.pipelines.create!( + source: :external, + sha: commit.sha, + ref: ref, + user: current_user) + end status = GenericCommitStatus.running_or_pending.find_or_initialize_by( project: @project, diff --git a/lib/api/commits.rb b/lib/api/commits.rb index 621b9dcecd9..c6fc17cc391 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -176,7 +176,7 @@ module API } if params[:path] - commit.raw_diffs(all_diffs: true).each do |diff| + commit.raw_diffs(limits: false).each do |diff| next unless diff.new_path == params[:path] lines = Gitlab::Diff::Parser.new.parse(diff.diff.each_line) diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 01cc8e8e1ca..ded5c65e303 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -100,6 +100,8 @@ module API expose :creator_id expose :namespace, using: 'API::Entities::Namespace' expose :forked_from_project, using: Entities::BasicProjectDetails, if: lambda{ |project, options| project.forked? } + expose :import_status + expose :import_error, if: lambda { |_project, options| options[:user_can_admin_project] } expose :avatar_url do |user, options| user.avatar_url(only_path: false) end @@ -152,7 +154,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 @@ -252,7 +257,9 @@ module API class RepoDiff < Grape::Entity expose :old_path, :new_path, :a_mode, :b_mode, :diff - expose :new_file, :renamed_file, :deleted_file + expose :new_file?, as: :new_file + expose :renamed_file?, as: :renamed_file + expose :deleted_file?, as: :deleted_file end class Milestone < ProjectEntity @@ -326,7 +333,7 @@ module API class MergeRequestChanges < MergeRequest expose :diffs, as: :changes, using: Entities::RepoDiff do |compare, _| - compare.raw_diffs(all_diffs: true).to_a + compare.raw_diffs(limits: false).to_a end end @@ -339,7 +346,7 @@ module API expose :commits, using: Entities::RepoCommit expose :diffs, using: Entities::RepoDiff do |compare, _| - compare.raw_diffs(all_diffs: true).to_a + compare.raw_diffs(limits: false).to_a end end @@ -543,7 +550,7 @@ module API end expose :diffs, using: Entities::RepoDiff do |compare, options| - compare.diffs(all_diffs: true).to_a + compare.diffs(limits: false).to_a end expose :compare_timeout do |compare, options| @@ -670,6 +677,7 @@ module API class Variable < Grape::Entity expose :key, :value + expose :protected?, as: :protected end class Pipeline < PipelineBasic @@ -681,6 +689,17 @@ module API expose :coverage end + class PipelineSchedule < Grape::Entity + expose :id + expose :description, :ref, :cron, :cron_timezone, :next_run_at, :active + expose :created_at, :updated_at + expose :owner, using: Entities::UserBasic + end + + class PipelineScheduleDetails < PipelineSchedule + expose :last_pipeline, using: Entities::PipelineBasic + end + class EnvironmentBasic < Grape::Entity expose :id, :name, :slug, :external_url end @@ -737,6 +756,28 @@ module API expose :impersonation end + class FeatureGate < Grape::Entity + expose :key + expose :value + end + + class Feature < Grape::Entity + expose :name + expose :state + expose :gates, using: FeatureGate do |model| + model.gates.map do |gate| + value = model.gate_values[gate.key] + + # By default all gate values are populated. Only show relevant ones. + if (value.is_a?(Integer) && value.zero?) || (value.is_a?(Set) && value.empty?) + next + end + + { key: gate.key, value: value } + end.compact + end + end + module JobRequest class JobInfo < Grape::Entity expose :name, :stage diff --git a/lib/api/features.rb b/lib/api/features.rb new file mode 100644 index 00000000000..cff0ba2ddff --- /dev/null +++ b/lib/api/features.rb @@ -0,0 +1,36 @@ +module API + class Features < Grape::API + before { authenticated_as_admin! } + + resource :features do + desc 'Get a list of all features' do + success Entities::Feature + end + get do + features = Feature.all + + present features, with: Entities::Feature, current_user: current_user + end + + desc 'Set the gate value for the given feature' do + success Entities::Feature + end + params do + requires :value, type: String, desc: '`true` or `false` to enable/disable, an integer for percentage of time' + end + post ':name' do + feature = Feature.get(params[:name]) + + if %w(0 false).include?(params[:value]) + feature.disable + elsif params[:value] == 'true' + feature.enable + else + feature.enable_percentage_of_time(params[:value].to_i) + end + + present feature, with: Entities::Feature, current_user: current_user + end + end + end +end diff --git a/lib/api/groups.rb b/lib/api/groups.rb index 3da7d735da8..e14a988a153 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 @@ -147,8 +151,8 @@ module API end get ":id/projects" do group = find_group!(params[:id]) - projects = GroupProjectsFinder.new(group: group, current_user: current_user).execute - projects = filter_projects(projects) + projects = GroupProjectsFinder.new(group: group, current_user: current_user, params: project_finder_params).execute + projects = reorder_projects(projects) entity = params[:simple] ? Entities::BasicProjectDetails : Entities::Project present paginate(projects), with: entity, current_user: current_user end diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 226a7ddd50e..2c73a6fdc4e 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -158,7 +158,7 @@ module API params_hash = custom_params || params attrs = {} keys.each do |key| - if params_hash[key].present? || (params_hash.has_key?(key) && params_hash[key] == false) + if params_hash[key].present? || (params_hash.key?(key) && params_hash[key] == false) attrs[key] = params_hash[key] end end @@ -256,31 +256,21 @@ module API # project helpers - def filter_projects(projects) - if params[:membership] - projects = projects.merge(current_user.authorized_projects) - end - - if params[:owned] - projects = projects.merge(current_user.owned_projects) - end - - if params[:starred] - projects = projects.merge(current_user.starred_projects) - end - - if params[:search].present? - projects = projects.search(params[:search]) - end - - if params[:visibility].present? - projects = projects.search_by_visibility(params[:visibility]) - end - - projects = projects.where(archived: params[:archived]) + def reorder_projects(projects) projects.reorder(params[:order_by] => params[:sort]) end + def project_finder_params + finder_params = {} + finder_params[:owned] = true if params[:owned].present? + finder_params[:non_public] = true if params[:membership].present? + finder_params[:starred] = true if params[:starred].present? + finder_params[:visibility_level] = Gitlab::VisibilityLevel.level_value(params[:visibility]) if params[:visibility] + finder_params[:archived] = params[:archived] + finder_params[:search] = params[:search] if params[:search] + finder_params + end + # file helpers def uploaded_file(field, uploads_path) @@ -321,6 +311,16 @@ module API end end + def present_artifacts!(artifacts_file) + return not_found! unless artifacts_file.exists? + + if artifacts_file.file_storage? + present_file!(artifacts_file.path, artifacts_file.filename) + else + redirect_to(artifacts_file.url) + end + end + private def private_token diff --git a/lib/api/jobs.rb b/lib/api/jobs.rb index 0223957fde1..8a67de10bca 100644 --- a/lib/api/jobs.rb +++ b/lib/api/jobs.rb @@ -224,16 +224,6 @@ module API find_build(id) || not_found! end - def present_artifacts!(artifacts_file) - if !artifacts_file.file_storage? - redirect_to(build.artifacts_file.url) - elsif artifacts_file.exists? - present_file!(artifacts_file.path, artifacts_file.filename) - else - not_found! - end - end - def filter_builds(builds, scope) return builds if scope.nil? || scope.empty? diff --git a/lib/api/pipeline_schedules.rb b/lib/api/pipeline_schedules.rb new file mode 100644 index 00000000000..93d89209934 --- /dev/null +++ b/lib/api/pipeline_schedules.rb @@ -0,0 +1,131 @@ +module API + class PipelineSchedules < Grape::API + include PaginationParams + + before { authenticate! } + + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :projects, requirements: { id: %r{[^/]+} } do + desc 'Get all pipeline schedules' do + success Entities::PipelineSchedule + end + params do + use :pagination + optional :scope, type: String, values: %w[active inactive], + desc: 'The scope of pipeline schedules' + end + get ':id/pipeline_schedules' do + authorize! :read_pipeline_schedule, user_project + + schedules = PipelineSchedulesFinder.new(user_project).execute(scope: params[:scope]) + .preload([:owner, :last_pipeline]) + present paginate(schedules), with: Entities::PipelineSchedule + end + + desc 'Get a single pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + end + get ':id/pipeline_schedules/:pipeline_schedule_id' do + authorize! :read_pipeline_schedule, user_project + + not_found!('PipelineSchedule') unless pipeline_schedule + + present pipeline_schedule, with: Entities::PipelineScheduleDetails + end + + desc 'Create a new pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :description, type: String, desc: 'The description of pipeline schedule' + requires :ref, type: String, desc: 'The branch/tag name will be triggered' + requires :cron, type: String, desc: 'The cron' + optional :cron_timezone, type: String, default: 'UTC', desc: 'The timezone' + optional :active, type: Boolean, default: true, desc: 'The activation of pipeline schedule' + end + post ':id/pipeline_schedules' do + authorize! :create_pipeline_schedule, user_project + + pipeline_schedule = Ci::CreatePipelineScheduleService + .new(user_project, current_user, declared_params(include_missing: false)) + .execute + + if pipeline_schedule.persisted? + present pipeline_schedule, with: Entities::PipelineScheduleDetails + else + render_validation_error!(pipeline_schedule) + end + end + + desc 'Edit a pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + optional :description, type: String, desc: 'The description of pipeline schedule' + optional :ref, type: String, desc: 'The branch/tag name will be triggered' + optional :cron, type: String, desc: 'The cron' + optional :cron_timezone, type: String, desc: 'The timezone' + optional :active, type: Boolean, desc: 'The activation of pipeline schedule' + end + put ':id/pipeline_schedules/:pipeline_schedule_id' do + authorize! :update_pipeline_schedule, user_project + + not_found!('PipelineSchedule') unless pipeline_schedule + + if pipeline_schedule.update(declared_params(include_missing: false)) + present pipeline_schedule, with: Entities::PipelineScheduleDetails + else + render_validation_error!(pipeline_schedule) + end + end + + desc 'Take ownership of a pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + end + post ':id/pipeline_schedules/:pipeline_schedule_id/take_ownership' do + authorize! :update_pipeline_schedule, user_project + + not_found!('PipelineSchedule') unless pipeline_schedule + + if pipeline_schedule.own!(current_user) + present pipeline_schedule, with: Entities::PipelineScheduleDetails + else + render_validation_error!(pipeline_schedule) + end + end + + desc 'Delete a pipeline schedule' do + success Entities::PipelineScheduleDetails + end + params do + requires :pipeline_schedule_id, type: Integer, desc: 'The pipeline schedule id' + end + delete ':id/pipeline_schedules/:pipeline_schedule_id' do + authorize! :admin_pipeline_schedule, user_project + + not_found!('PipelineSchedule') unless pipeline_schedule + + status :accepted + present pipeline_schedule.destroy, with: Entities::PipelineScheduleDetails + end + end + + helpers do + def pipeline_schedule + @pipeline_schedule ||= + user_project.pipeline_schedules + .preload(:owner, :last_pipeline) + .find_by(id: params.delete(:pipeline_schedule_id)) + end + end + end +end diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb index 9117704aa46..e505cae3992 100644 --- a/lib/api/pipelines.rb +++ b/lib/api/pipelines.rb @@ -47,7 +47,7 @@ module API new_pipeline = Ci::CreatePipelineService.new(user_project, current_user, declared_params(include_missing: false)) - .execute(ignore_skip_ci: true, save_on_errors: false) + .execute(:api, ignore_skip_ci: true, save_on_errors: false) if new_pipeline.persisted? present new_pipeline, with: Entities::Pipeline else diff --git a/lib/api/projects.rb b/lib/api/projects.rb index ed5004e8d1a..deac3934d57 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -21,6 +21,7 @@ module API optional :request_access_enabled, type: Boolean, desc: 'Allow users to request member access' optional :only_allow_merge_if_pipeline_succeeds, type: Boolean, desc: 'Only allow to merge if builds succeed' optional :only_allow_merge_if_all_discussions_are_resolved, type: Boolean, desc: 'Only allow to merge if all discussions are resolved' + optional :tag_list, type: Array[String], desc: 'The list of tags for a project' end params :optional_params do @@ -58,6 +59,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 @@ -65,16 +68,19 @@ module API optional :import_url, type: String, desc: 'URL from which the project is imported' end - def present_projects(projects, options = {}) + def present_projects(options = {}) + projects = ProjectsFinder.new(current_user: current_user, params: project_finder_params).execute + projects = reorder_projects(projects) + projects = projects.with_statistics if params[:statistics] + projects = projects.with_issues_enabled if params[:with_issues_enabled] + projects = projects.with_merge_requests_enabled if params[:with_merge_requests_enabled] + options = options.reverse_merge( - with: Entities::Project, - current_user: current_user, - simple: params[:simple] + with: current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails, + statistics: params[:statistics], + current_user: current_user ) - - projects = filter_projects(projects) - projects = projects.with_statistics if options[:statistics] - options[:with] = Entities::BasicProjectDetails if options[:simple] + options[:with] = Entities::BasicProjectDetails if params[:simple] present paginate(projects), options end @@ -88,8 +94,7 @@ module API use :statistics_params end get do - entity = current_user ? Entities::ProjectWithAccess : Entities::BasicProjectDetails - present_projects ProjectsFinder.new(current_user: current_user).execute, with: entity, statistics: params[:statistics] + present_projects end desc 'Create new project' do @@ -104,7 +109,7 @@ module API end post do attrs = declared_params(include_missing: false) - attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.has_key?(:jobs_enabled) + attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.key?(:jobs_enabled) project = ::Projects::CreateService.new(current_user, attrs).execute if project.saved? @@ -124,6 +129,7 @@ module API params do requires :name, type: String, desc: 'The name of the project' requires :user_id, type: Integer, desc: 'The ID of a user' + optional :path, type: String, desc: 'The path of the repository' optional :default_branch, type: String, desc: 'The default branch of the project' use :optional_params use :create_params @@ -225,6 +231,7 @@ module API :request_access_enabled, :shared_runners_enabled, :snippets_enabled, + :tag_list, :visibility, :wiki_enabled ] @@ -241,7 +248,7 @@ module API authorize! :rename_project, user_project if attrs[:name].present? authorize! :change_visibility_level, user_project if attrs[:visibility].present? - attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.has_key?(:jobs_enabled) + attrs[:builds_enabled] = attrs.delete(:jobs_enabled) if attrs.key?(:jobs_enabled) result = ::Projects::UpdateService.new(user_project, current_user, attrs).execute diff --git a/lib/api/repositories.rb b/lib/api/repositories.rb index 8f16e532ecb..14d2bff9cb5 100644 --- a/lib/api/repositories.rb +++ b/lib/api/repositories.rb @@ -85,7 +85,7 @@ module API optional :sha, type: String, desc: 'The commit sha of the archive to be downloaded' optional :format, type: String, desc: 'The archive format' end - get ':id/repository/archive', requirements: { format: Gitlab::Regex.archive_formats_regex } do + get ':id/repository/archive', requirements: { format: Gitlab::PathRegex.archive_formats_regex } do begin send_git_archive user_project.repository, ref: params[:sha], format: params[:format] rescue diff --git a/lib/api/runner.rb b/lib/api/runner.rb index 6fbb02cb3aa..4552115b3e2 100644 --- a/lib/api/runner.rb +++ b/lib/api/runner.rb @@ -141,7 +141,7 @@ module API patch '/:id/trace' do job = authenticate_job! - error!('400 Missing header Content-Range', 400) unless request.headers.has_key?('Content-Range') + error!('400 Missing header Content-Range', 400) unless request.headers.key?('Content-Range') content_range = request.headers['Content-Range'] content_range = content_range.split('-') @@ -241,16 +241,7 @@ module API get '/:id/artifacts' do job = authenticate_job! - artifacts_file = job.artifacts_file - unless artifacts_file.file_storage? - return redirect_to job.artifacts_file.url - end - - unless artifacts_file.exists? - not_found! - end - - present_file!(artifacts_file.path, artifacts_file.filename) + present_artifacts!(job.artifacts_file) end end end diff --git a/lib/api/services.rb b/lib/api/services.rb index cb07df9e249..47bd9940f77 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/time_tracking_endpoints.rb b/lib/api/time_tracking_endpoints.rb index 05b4b490e27..df4632346dd 100644 --- a/lib/api/time_tracking_endpoints.rb +++ b/lib/api/time_tracking_endpoints.rb @@ -5,7 +5,7 @@ module API included do helpers do def issuable_name - declared_params.has_key?(:issue_iid) ? 'issue' : 'merge_request' + declared_params.key?(:issue_iid) ? 'issue' : 'merge_request' end def issuable_key diff --git a/lib/api/users.rb b/lib/api/users.rb index 3d83720b7b9..2070dbd8bc7 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -286,13 +286,14 @@ module API end params do requires :id, type: Integer, desc: 'The ID of the user' + optional :hard_delete, type: Boolean, desc: "Whether to remove a user's contributions" end delete ":id" do authenticated_as_admin! user = User.find_by(id: params[:id]) not_found!('User') unless user - DeleteUserWorker.perform_async(current_user.id, user.id) + DeleteUserWorker.perform_async(current_user.id, user.id, hard_delete: params[:hard_delete]) end desc 'Block a user. Available only for admins.' diff --git a/lib/api/v3/builds.rb b/lib/api/v3/builds.rb index 21935922414..93ad9eb26b8 100644 --- a/lib/api/v3/builds.rb +++ b/lib/api/v3/builds.rb @@ -225,16 +225,6 @@ module API find_build(id) || not_found! end - def present_artifacts!(artifacts_file) - if !artifacts_file.file_storage? - redirect_to(build.artifacts_file.url) - elsif artifacts_file.exists? - present_file!(artifacts_file.path, artifacts_file.filename) - else - not_found! - end - end - def filter_builds(builds, scope) return builds if scope.nil? || scope.empty? diff --git a/lib/api/v3/commits.rb b/lib/api/v3/commits.rb index 674de592f0a..5936f4700aa 100644 --- a/lib/api/v3/commits.rb +++ b/lib/api/v3/commits.rb @@ -167,7 +167,7 @@ module API } if params[:path] - commit.raw_diffs(all_diffs: true).each do |diff| + commit.raw_diffs(limits: false).each do |diff| next unless diff.new_path == params[:path] lines = Gitlab::Diff::Parser.new.parse(diff.diff.each_line) diff --git a/lib/api/v3/deploy_keys.rb b/lib/api/v3/deploy_keys.rb index bbb174b6003..b90e2061da3 100644 --- a/lib/api/v3/deploy_keys.rb +++ b/lib/api/v3/deploy_keys.rb @@ -41,6 +41,7 @@ module API params do requires :key, type: String, desc: 'The new deploy key' requires :title, type: String, desc: 'The name of the deploy key' + optional :can_push, type: Boolean, desc: "Can deploy key push to the project's repository" end post ":id/#{path}" do params[:key].strip! diff --git a/lib/api/v3/entities.rb b/lib/api/v3/entities.rb index 332f233bf5e..7c5065dee90 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 @@ -223,7 +226,7 @@ module API class MergeRequestChanges < MergeRequest expose :diffs, as: :changes, using: ::API::Entities::RepoDiff do |compare, _| - compare.raw_diffs(all_diffs: true).to_a + compare.raw_diffs(limits: false).to_a end end diff --git a/lib/api/v3/groups.rb b/lib/api/v3/groups.rb index 6187445fc8d..2c52d21fa1c 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/api/v3/helpers.rb b/lib/api/v3/helpers.rb index 0f234d4cdad..d9e76560d03 100644 --- a/lib/api/v3/helpers.rb +++ b/lib/api/v3/helpers.rb @@ -14,6 +14,33 @@ module API authorize! access_level, merge_request merge_request end + + # project helpers + + def filter_projects(projects) + if params[:membership] + projects = projects.merge(current_user.authorized_projects) + end + + if params[:owned] + projects = projects.merge(current_user.owned_projects) + end + + if params[:starred] + projects = projects.merge(current_user.starred_projects) + end + + if params[:search].present? + projects = projects.search(params[:search]) + end + + if params[:visibility].present? + projects = projects.where(visibility_level: Gitlab::VisibilityLevel.level_value(params[:visibility])) + end + + projects = projects.where(archived: params[:archived]) + projects.reorder(params[:order_by] => params[:sort]) + end end end end diff --git a/lib/api/v3/projects.rb b/lib/api/v3/projects.rb index 164612cb8dd..20976b9dd08 100644 --- a/lib/api/v3/projects.rb +++ b/lib/api/v3/projects.rb @@ -44,7 +44,7 @@ module API end def set_only_allow_merge_if_pipeline_succeeds! - if params.has_key?(:only_allow_merge_if_build_succeeds) + if params.key?(:only_allow_merge_if_build_succeeds) params[:only_allow_merge_if_pipeline_succeeds] = params.delete(:only_allow_merge_if_build_succeeds) end end @@ -147,7 +147,7 @@ module API get '/starred' do authenticate! - present_projects current_user.viewable_starred_projects + present_projects ProjectsFinder.new(current_user: current_user, params: { starred: true }).execute end desc 'Get all projects for admin user' do diff --git a/lib/api/v3/repositories.rb b/lib/api/v3/repositories.rb index e4d14bc8168..0eaa0de2eef 100644 --- a/lib/api/v3/repositories.rb +++ b/lib/api/v3/repositories.rb @@ -72,7 +72,7 @@ module API optional :sha, type: String, desc: 'The commit sha of the archive to be downloaded' optional :format, type: String, desc: 'The archive format' end - get ':id/repository/archive', requirements: { format: Gitlab::Regex.archive_formats_regex } do + get ':id/repository/archive', requirements: { format: Gitlab::PathRegex.archive_formats_regex } do begin send_git_archive user_project.repository, ref: params[:sha], format: params[:format] rescue diff --git a/lib/api/v3/time_tracking_endpoints.rb b/lib/api/v3/time_tracking_endpoints.rb index 81ae4e8137d..d5b90e435ba 100644 --- a/lib/api/v3/time_tracking_endpoints.rb +++ b/lib/api/v3/time_tracking_endpoints.rb @@ -6,7 +6,7 @@ module API included do helpers do def issuable_name - declared_params.has_key?(:issue_id) ? 'issue' : 'merge_request' + declared_params.key?(:issue_id) ? 'issue' : 'merge_request' end def issuable_key diff --git a/lib/api/variables.rb b/lib/api/variables.rb index 5acde41551b..381c4ef50b0 100644 --- a/lib/api/variables.rb +++ b/lib/api/variables.rb @@ -42,6 +42,7 @@ module API params do requires :key, type: String, desc: 'The key of the variable' requires :value, type: String, desc: 'The value of the variable' + optional :protected, type: String, desc: 'Whether the variable is protected' end post ':id/variables' do variable = user_project.variables.create(declared(params, include_parent_namespaces: false).to_h) @@ -59,13 +60,14 @@ module API params do optional :key, type: String, desc: 'The key of the variable' optional :value, type: String, desc: 'The value of the variable' + optional :protected, type: String, desc: 'Whether the variable is protected' end put ':id/variables/:key' do variable = user_project.variables.find_by(key: params[:key]) return not_found!('Variable') unless variable - if variable.update(value: params[:value]) + if variable.update(declared_params(include_missing: false).except(:key)) present variable, with: Entities::Variable else render_validation_error!(variable) diff --git a/lib/backup/artifacts.rb b/lib/backup/artifacts.rb index 51fa3867e67..1f4bda6f588 100644 --- a/lib/backup/artifacts.rb +++ b/lib/backup/artifacts.rb @@ -3,7 +3,7 @@ require 'backup/files' module Backup class Artifacts < Files def initialize - super('artifacts', ArtifactUploader.artifacts_path) + super('artifacts', ArtifactUploader.local_artifacts_store) end def create_files_dir diff --git a/lib/banzai/reference_parser/base_parser.rb b/lib/banzai/reference_parser/base_parser.rb index c2503fa2adc..d99a3bfa625 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/bitbucket/representation/pull_request_comment.rb b/lib/bitbucket/representation/pull_request_comment.rb index 4f8efe03bae..c52acbc3ddc 100644 --- a/lib/bitbucket/representation/pull_request_comment.rb +++ b/lib/bitbucket/representation/pull_request_comment.rb @@ -22,11 +22,11 @@ module Bitbucket end def inline? - raw.has_key?('inline') + raw.key?('inline') end def has_parent? - raw.has_key?('parent') + raw.key?('parent') end private diff --git a/lib/ci/api/builds.rb b/lib/ci/api/builds.rb index 67b269b330c..e2e91ce99cd 100644 --- a/lib/ci/api/builds.rb +++ b/lib/ci/api/builds.rb @@ -88,7 +88,7 @@ module Ci patch ":id/trace.txt" do build = authenticate_build! - error!('400 Missing header Content-Range', 400) unless request.headers.has_key?('Content-Range') + error!('400 Missing header Content-Range', 400) unless request.headers.key?('Content-Range') content_range = request.headers['Content-Range'] content_range = content_range.split('-') @@ -187,14 +187,14 @@ module Ci build = authenticate_build! artifacts_file = build.artifacts_file - unless artifacts_file.file_storage? - return redirect_to build.artifacts_file.url - end - unless artifacts_file.exists? not_found! end + unless artifacts_file.file_storage? + return redirect_to build.artifacts_file.url + end + present_file!(artifacts_file.path, artifacts_file.filename) end diff --git a/lib/constraints/group_url_constrainer.rb b/lib/constraints/group_url_constrainer.rb index 0ea2f97352d..6fc1d56d7a0 100644 --- a/lib/constraints/group_url_constrainer.rb +++ b/lib/constraints/group_url_constrainer.rb @@ -1,9 +1,9 @@ class GroupUrlConstrainer def matches?(request) - id = request.params[:group_id] || request.params[:id] + full_path = request.params[:group_id] || request.params[:id] - return false unless DynamicPathValidator.valid_namespace_path?(id) + return false unless DynamicPathValidator.valid_group_path?(full_path) - Group.find_by_full_path(id, follow_redirects: request.get?).present? + Group.find_by_full_path(full_path, follow_redirects: request.get?).present? end end diff --git a/lib/constraints/project_url_constrainer.rb b/lib/constraints/project_url_constrainer.rb index 4444a1abee3..4c0aee6c48f 100644 --- a/lib/constraints/project_url_constrainer.rb +++ b/lib/constraints/project_url_constrainer.rb @@ -2,7 +2,7 @@ class ProjectUrlConstrainer def matches?(request) namespace_path = request.params[:namespace_id] project_path = request.params[:project_id] || request.params[:id] - full_path = namespace_path + '/' + project_path + full_path = [namespace_path, project_path].join('/') return false unless DynamicPathValidator.valid_project_path?(full_path) diff --git a/lib/constraints/user_url_constrainer.rb b/lib/constraints/user_url_constrainer.rb index 28159dc0dec..d16ae7f3f40 100644 --- a/lib/constraints/user_url_constrainer.rb +++ b/lib/constraints/user_url_constrainer.rb @@ -1,5 +1,9 @@ class UserUrlConstrainer def matches?(request) - User.find_by_full_path(request.params[:username], follow_redirects: request.get?).present? + full_path = request.params[:username] + + return false unless DynamicPathValidator.valid_user_path?(full_path) + + User.find_by_full_path(full_path, follow_redirects: request.get?).present? end end diff --git a/lib/feature.rb b/lib/feature.rb new file mode 100644 index 00000000000..2e2b343f82c --- /dev/null +++ b/lib/feature.rb @@ -0,0 +1,41 @@ +require 'flipper/adapters/active_record' + +class Feature + # Classes to override flipper table names + class FlipperFeature < Flipper::Adapters::ActiveRecord::Feature + # Using `self.table_name` won't work. ActiveRecord bug? + superclass.table_name = 'features' + end + + class FlipperGate < Flipper::Adapters::ActiveRecord::Gate + superclass.table_name = 'feature_gates' + end + + class << self + def all + flipper.features.to_a + end + + def get(key) + flipper.feature(key) + end + + def persisted?(feature) + # Flipper creates on-memory features when asked for a not-yet-created one. + # If we want to check if a feature has been actually set, we look for it + # on the persisted features list. + all.map(&:name).include?(feature.name) + end + + private + + def flipper + @flipper ||= begin + adapter = Flipper::Adapters::ActiveRecord.new( + feature_class: FlipperFeature, gate_class: FlipperGate) + + Flipper.new(adapter) + end + end + end +end diff --git a/lib/gitlab/chat_commands/presenters/base.rb b/lib/gitlab/chat_commands/presenters/base.rb index 2700a5a2ad5..05994bee79d 100644 --- a/lib/gitlab/chat_commands/presenters/base.rb +++ b/lib/gitlab/chat_commands/presenters/base.rb @@ -45,9 +45,9 @@ module Gitlab end def format_response(response) - response[:text] = format(response[:text]) if response.has_key?(:text) + response[:text] = format(response[:text]) if response.key?(:text) - if response.has_key?(:attachments) + if response.key?(:attachments) response[:attachments].each do |attachment| attachment[:pretext] = format(attachment[:pretext]) if attachment[:pretext] attachment[:text] = format(attachment[:text]) if attachment[:text] diff --git a/lib/gitlab/ci/status/build/cancelable.rb b/lib/gitlab/ci/status/build/cancelable.rb index 57b533bad99..439ef0ce015 100644 --- a/lib/gitlab/ci/status/build/cancelable.rb +++ b/lib/gitlab/ci/status/build/cancelable.rb @@ -12,7 +12,7 @@ module Gitlab end def action_path - cancel_namespace_project_build_path(subject.project.namespace, + cancel_namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/ci/status/build/common.rb b/lib/gitlab/ci/status/build/common.rb index 3fec2c5d4db..b173c23fba4 100644 --- a/lib/gitlab/ci/status/build/common.rb +++ b/lib/gitlab/ci/status/build/common.rb @@ -8,7 +8,7 @@ module Gitlab end def details_path - namespace_project_build_path(subject.project.namespace, + namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/ci/status/build/play.rb b/lib/gitlab/ci/status/build/play.rb index c6139f1b716..e80f3263794 100644 --- a/lib/gitlab/ci/status/build/play.rb +++ b/lib/gitlab/ci/status/build/play.rb @@ -20,7 +20,7 @@ module Gitlab end def action_path - play_namespace_project_build_path(subject.project.namespace, + play_namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/ci/status/build/retryable.rb b/lib/gitlab/ci/status/build/retryable.rb index 505f80848b2..56303e4cb17 100644 --- a/lib/gitlab/ci/status/build/retryable.rb +++ b/lib/gitlab/ci/status/build/retryable.rb @@ -16,7 +16,7 @@ module Gitlab end def action_path - retry_namespace_project_build_path(subject.project.namespace, + retry_namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/ci/status/build/stop.rb b/lib/gitlab/ci/status/build/stop.rb index 0b5199e5483..2778d6f3b52 100644 --- a/lib/gitlab/ci/status/build/stop.rb +++ b/lib/gitlab/ci/status/build/stop.rb @@ -20,7 +20,7 @@ module Gitlab end def action_path - play_namespace_project_build_path(subject.project.namespace, + play_namespace_project_job_path(subject.project.namespace, subject.project, subject) end diff --git a/lib/gitlab/ci/trace/stream.rb b/lib/gitlab/ci/trace/stream.rb index fa462cbe095..c4c0623df6c 100644 --- a/lib/gitlab/ci/trace/stream.rb +++ b/lib/gitlab/ci/trace/stream.rb @@ -73,7 +73,7 @@ module Gitlab match = "" - stream.each_line do |line| + reverse_line do |line| matches = line.scan(regex) next unless matches.is_a?(Array) next if matches.empty? @@ -86,34 +86,39 @@ module Gitlab nil rescue # if bad regex or something goes wrong we dont want to interrupt transition - # so we just silentrly ignore error for now + # so we just silently ignore error for now end private - def read_last_lines(last_lines) - chunks = [] - pos = lines = 0 - max = stream.size - - # We want an extra line to make sure fist line has full contents - while lines <= last_lines && pos < max - pos += BUFFER_SIZE - - buf = - if pos <= max - stream.seek(-pos, IO::SEEK_END) - stream.read(BUFFER_SIZE) - else # Reached the head, read only left - stream.seek(0) - stream.read(BUFFER_SIZE - (pos - max)) - end - - lines += buf.count("\n") - chunks.unshift(buf) + def read_last_lines(limit) + to_enum(:reverse_line).first(limit).reverse.join + end + + def reverse_line + stream.seek(0, IO::SEEK_END) + debris = '' + + until (buf = read_backward(BUFFER_SIZE)).empty? + buf += debris + debris, *lines = buf.each_line.to_a + lines.reverse_each do |line| + yield(line.force_encoding('UTF-8')) + end end - chunks.join.lines.last(last_lines).join + yield(debris.force_encoding('UTF-8')) unless debris.empty? + end + + def read_backward(length) + cur_offset = stream.tell + start = cur_offset - length + start = 0 if start < 0 + + stream.seek(start, IO::SEEK_SET) + stream.read(cur_offset - start).tap do + stream.seek(start, IO::SEEK_SET) + end end end end diff --git a/lib/gitlab/current_settings.rb b/lib/gitlab/current_settings.rb index 82576d197fe..9e14b35b0f8 100644 --- a/lib/gitlab/current_settings.rb +++ b/lib/gitlab/current_settings.rb @@ -19,7 +19,7 @@ module Gitlab settings = ::ApplicationSetting.last end - settings ||= ::ApplicationSetting.create_from_defaults unless ActiveRecord::Migrator.needs_migration? + settings ||= ::ApplicationSetting.create_from_defaults end settings || in_memory_application_settings @@ -46,7 +46,8 @@ module Gitlab active_db_connection = ActiveRecord::Base.connection.active? rescue false active_db_connection && - ActiveRecord::Base.connection.table_exists?('application_settings') + ActiveRecord::Base.connection.table_exists?('application_settings') && + !ActiveRecord::Migrator.needs_migration? rescue ActiveRecord::NoDatabaseError false end diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index e76c9abbe04..a412bb6dbd2 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -42,7 +42,7 @@ module Gitlab 'in the body of your migration class' end - if Database.postgresql? + if supports_drop_index_concurrently? options = options.merge({ algorithm: :concurrently }) disable_statement_timeout end @@ -50,6 +50,39 @@ module Gitlab remove_index(table_name, options.merge({ column: column_name })) end + # Removes an existing index, concurrently when supported + # + # On PostgreSQL this method removes an index concurrently. + # + # Example: + # + # remove_concurrent_index :users, "index_X_by_Y" + # + # See Rails' `remove_index` for more info on the available arguments. + def remove_concurrent_index_by_name(table_name, index_name, options = {}) + if transaction_open? + raise 'remove_concurrent_index_by_name can not be run inside a transaction, ' \ + 'you can disable transactions by calling disable_ddl_transaction! ' \ + 'in the body of your migration class' + end + + if supports_drop_index_concurrently? + options = options.merge({ algorithm: :concurrently }) + disable_statement_timeout + end + + remove_index(table_name, options.merge({ name: index_name })) + end + + # Only available on Postgresql >= 9.2 + def supports_drop_index_concurrently? + return false unless Database.postgresql? + + version = select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i + + version >= 90200 + end + # Adds a foreign key with only minimal locking on the tables involved. # # This method only requires minimal locking when using PostgreSQL. When diff --git a/lib/gitlab/dependency_linker.rb b/lib/gitlab/dependency_linker.rb index c45ae8feb2c..3192bf6f667 100644 --- a/lib/gitlab/dependency_linker.rb +++ b/lib/gitlab/dependency_linker.rb @@ -1,7 +1,16 @@ module Gitlab module DependencyLinker LINKERS = [ - GemfileLinker + GemfileLinker, + GemspecLinker, + PackageJsonLinker, + ComposerJsonLinker, + PodfileLinker, + PodspecLinker, + PodspecJsonLinker, + CartfileLinker, + GodepsJsonLinker, + RequirementsTxtLinker ].freeze def self.linker(blob_name) diff --git a/lib/gitlab/dependency_linker/base_linker.rb b/lib/gitlab/dependency_linker/base_linker.rb index 5f4027e7e81..7bbd154eb03 100644 --- a/lib/gitlab/dependency_linker/base_linker.rb +++ b/lib/gitlab/dependency_linker/base_linker.rb @@ -1,6 +1,9 @@ module Gitlab module DependencyLinker class BaseLinker + URL_REGEX = %r{https?://[^'" ]+}.freeze + REPO_REGEX = %r{[^/'" ]+/[^/'" ]+}.freeze + class_attribute :file_type def self.support?(blob_name) @@ -26,59 +29,20 @@ module Gitlab private - def package_url(name) - raise NotImplementedError - end - def link_dependencies raise NotImplementedError end - def package_link(name, url = package_url(name)) - return name unless url - - %{<a href="#{ERB::Util.html_escape_once(url)}" rel="noopener noreferrer" target="_blank">#{ERB::Util.html_escape_once(name)}</a>} + def license_url(name) + Licensee::License.find(name)&.url end - # Links package names in a method call or assignment string argument. - # - # Example: - # link_method_call("gem") - # # Will link `package` in `gem "package"`, `gem("package")` and `gem = "package"` - # - # link_method_call("gem", "specific_package") - # # Will link `specific_package` in `gem "specific_package"` - # - # link_method_call("github", /[^\/]+\/[^\/]+/) - # # Will link `user/repo` in `github "user/repo"`, but not `github "package"` - # - # link_method_call(%w[add_dependency add_development_dependency]) - # # Will link `spec.add_dependency "package"` and `spec.add_development_dependency "package"` - # - # link_method_call("name") - # # Will link `package` in `self.name = "package"` - def link_method_call(method_names, value = nil, &url_proc) - value = - case value - when String - Regexp.escape(value) - when nil - /[^'"]+/ - else - value - end - - method_names = Array(method_names).map { |name| Regexp.escape(name) } - - regex = %r{ - #{Regexp.union(method_names)} # Method name - \s* # Whitespace - [(=]? # Opening brace or equals sign - \s* # Whitespace - ['"](?<name>#{value})['"] # Package name in quotes - }x + def github_url(name) + "https://github.com/#{name}" + end - link_regex(regex, &url_proc) + def link_tag(name, url) + %{<a href="#{ERB::Util.html_escape_once(url)}" rel="nofollow noreferrer noopener" target="_blank">#{ERB::Util.html_escape_once(name)}</a>} end # Links package names based on regex. @@ -86,13 +50,13 @@ module Gitlab # Example: # link_regex(/(github:|:github =>)\s*['"](?<name>[^'"]+)['"]/) # # Will link `user/repo` in `github: "user/repo"` or `:github => "user/repo"` - def link_regex(regex) + def link_regex(regex, &url_proc) highlighted_lines.map!.with_index do |rich_line, i| marker = StringRegexMarker.new(plain_lines[i], rich_line.html_safe) marker.mark(regex, group: :name) do |text, left:, right:| - url = block_given? ? yield(text) : package_url(text) - package_link(text, url) + url = yield(text) + url ? link_tag(text, url) : text end end end @@ -104,6 +68,19 @@ module Gitlab def highlighted_lines @highlighted_lines ||= highlighted_text.lines end + + def regexp_for_value(value, default: /[^'" ]+/) + case value + when Array + Regexp.union(value.map { |v| regexp_for_value(v, default: default) }) + when String + Regexp.escape(value) + when Regexp + value + else + default + end + end end end end diff --git a/lib/gitlab/dependency_linker/cartfile_linker.rb b/lib/gitlab/dependency_linker/cartfile_linker.rb new file mode 100644 index 00000000000..4f69f2c4ab2 --- /dev/null +++ b/lib/gitlab/dependency_linker/cartfile_linker.rb @@ -0,0 +1,14 @@ +module Gitlab + module DependencyLinker + class CartfileLinker < MethodLinker + self.file_type = :cartfile + + private + + def link_dependencies + link_method_call('github', REPO_REGEX, &method(:github_url)) + link_method_call(%w[github git binary], URL_REGEX, &:itself) + end + end + end +end diff --git a/lib/gitlab/dependency_linker/cocoapods.rb b/lib/gitlab/dependency_linker/cocoapods.rb new file mode 100644 index 00000000000..2fbde7da1b4 --- /dev/null +++ b/lib/gitlab/dependency_linker/cocoapods.rb @@ -0,0 +1,10 @@ +module Gitlab + module DependencyLinker + module Cocoapods + def package_url(name) + package = name.split("/", 2).first + "https://cocoapods.org/pods/#{package}" + end + end + end +end diff --git a/lib/gitlab/dependency_linker/composer_json_linker.rb b/lib/gitlab/dependency_linker/composer_json_linker.rb new file mode 100644 index 00000000000..0245bf4077a --- /dev/null +++ b/lib/gitlab/dependency_linker/composer_json_linker.rb @@ -0,0 +1,18 @@ +module Gitlab + module DependencyLinker + class ComposerJsonLinker < PackageJsonLinker + self.file_type = :composer_json + + private + + def link_packages + link_packages_at_key("require", &method(:package_url)) + link_packages_at_key("require-dev", &method(:package_url)) + end + + def package_url(name) + "https://packagist.org/packages/#{name}" if name =~ %r{\A#{REPO_REGEX}\z} + end + end + end +end diff --git a/lib/gitlab/dependency_linker/gemfile_linker.rb b/lib/gitlab/dependency_linker/gemfile_linker.rb index 9b82e126528..d034ea67387 100644 --- a/lib/gitlab/dependency_linker/gemfile_linker.rb +++ b/lib/gitlab/dependency_linker/gemfile_linker.rb @@ -1,28 +1,31 @@ module Gitlab module DependencyLinker - class GemfileLinker < BaseLinker + class GemfileLinker < MethodLinker self.file_type = :gemfile private def link_dependencies - # Link `gem "package_name"` to https://rubygems.org/gems/package_name - link_method_call("gem") + link_urls + link_packages + end + def link_urls # Link `github: "user/repo"` to https://github.com/user/repo - link_regex(/(github:|:github\s*=>)\s*['"](?<name>[^'"]+)['"]/) do |name| - "https://github.com/#{name}" - end + link_regex(/(github:|:github\s*=>)\s*['"](?<name>[^'"]+)['"]/, &method(:github_url)) # Link `git: "https://gitlab.example.com/user/repo"` to https://gitlab.example.com/user/repo - link_regex(%r{(git:|:git\s*=>)\s*['"](?<name>https?://[^'"]+)['"]}) { |url| url } + link_regex(%r{(git:|:git\s*=>)\s*['"](?<name>#{URL_REGEX})['"]}, &:itself) # Link `source "https://rubygems.org"` to https://rubygems.org - link_method_call("source", %r{https?://[^'"]+}) { |url| url } + link_method_call('source', URL_REGEX, &:itself) end - def package_url(name) - "https://rubygems.org/gems/#{name}" + def link_packages + # Link `gem "package_name"` to https://rubygems.org/gems/package_name + link_method_call('gem') do |name| + "https://rubygems.org/gems/#{name}" + end end end end diff --git a/lib/gitlab/dependency_linker/gemspec_linker.rb b/lib/gitlab/dependency_linker/gemspec_linker.rb new file mode 100644 index 00000000000..f1783ee2ab4 --- /dev/null +++ b/lib/gitlab/dependency_linker/gemspec_linker.rb @@ -0,0 +1,18 @@ +module Gitlab + module DependencyLinker + class GemspecLinker < MethodLinker + self.file_type = :gemspec + + private + + def link_dependencies + link_method_call('homepage', URL_REGEX, &:itself) + link_method_call('license', &method(:license_url)) + + link_method_call(%w[name add_dependency add_runtime_dependency add_development_dependency]) do |name| + "https://rubygems.org/gems/#{name}" + end + end + end + end +end diff --git a/lib/gitlab/dependency_linker/godeps_json_linker.rb b/lib/gitlab/dependency_linker/godeps_json_linker.rb new file mode 100644 index 00000000000..fe091baee6d --- /dev/null +++ b/lib/gitlab/dependency_linker/godeps_json_linker.rb @@ -0,0 +1,26 @@ +module Gitlab + module DependencyLinker + class GodepsJsonLinker < JsonLinker + NESTED_REPO_REGEX = %r{([^/]+/)+[^/]+?}.freeze + + self.file_type = :godeps_json + + private + + def link_dependencies + link_json('ImportPath') do |path| + case path + when %r{\A(?<repo>gitlab\.com/#{NESTED_REPO_REGEX})\.git/(?<path>.+)\z}, + %r{\A(?<repo>git(lab|hub)\.com/#{REPO_REGEX})/(?<path>.+)\z} + + "https://#{$~[:repo]}/tree/master/#{$~[:path]}" + when /\Agolang\.org/ + "https://godoc.org/#{path}" + else + "https://#{path}" + end + end + end + end + end +end diff --git a/lib/gitlab/dependency_linker/json_linker.rb b/lib/gitlab/dependency_linker/json_linker.rb new file mode 100644 index 00000000000..a8ef25233d8 --- /dev/null +++ b/lib/gitlab/dependency_linker/json_linker.rb @@ -0,0 +1,44 @@ +module Gitlab + module DependencyLinker + class JsonLinker < BaseLinker + def link + return highlighted_text unless json + + super + end + + private + + # Links package names in a JSON key or values. + # + # Example: + # link_json('name') + # # Will link `package` in `"name": "package"` + # + # link_json('name', 'specific_package') + # # Will link `specific_package` in `"name": "specific_package"` + # + # link_json('name', /[^\/]+\/[^\/]+/) + # # Will link `user/repo` in `"name": "user/repo"`, but not `"name": "package"` + # + # 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: /[^" ]+/) + + if link == :value + value = /(?<name>#{value})/ + else + key = /(?<name>#{key})/ + end + + link_regex(/"#{key}":\s*"#{value}"/, &url_proc) + end + + def json + @json ||= JSON.parse(plain_text) rescue nil + end + end + end +end diff --git a/lib/gitlab/dependency_linker/method_linker.rb b/lib/gitlab/dependency_linker/method_linker.rb new file mode 100644 index 00000000000..0ffa2a83c93 --- /dev/null +++ b/lib/gitlab/dependency_linker/method_linker.rb @@ -0,0 +1,39 @@ +module Gitlab + module DependencyLinker + class MethodLinker < BaseLinker + private + + # Links package names in a method call or assignment string argument. + # + # Example: + # link_method_call('gem') + # # Will link `package` in `gem "package"`, `gem("package")` and `gem = "package"` + # + # link_method_call('gem', 'specific_package') + # # Will link `specific_package` in `gem "specific_package"` + # + # link_method_call('github', /[^\/"]+\/[^\/"]+/) + # # Will link `user/repo` in `github "user/repo"`, but not `github "package"` + # + # link_method_call(%w[add_dependency add_development_dependency]) + # # Will link `spec.add_dependency "package"` and `spec.add_development_dependency "package"` + # + # link_method_call('name') + # # Will link `package` in `self.name = "package"` + def link_method_call(method_name, value = nil, &url_proc) + method_name = regexp_for_value(method_name) + value = regexp_for_value(value) + + regex = %r{ + #{method_name} # Method name + \s* # Whitespace + [(=]? # Opening brace or equals sign + \s* # Whitespace + ['"](?<name>#{value})['"] # Package name in quotes + }x + + link_regex(regex, &url_proc) + end + end + end +end diff --git a/lib/gitlab/dependency_linker/package_json_linker.rb b/lib/gitlab/dependency_linker/package_json_linker.rb new file mode 100644 index 00000000000..330c95f0880 --- /dev/null +++ b/lib/gitlab/dependency_linker/package_json_linker.rb @@ -0,0 +1,44 @@ +module Gitlab + module DependencyLinker + class PackageJsonLinker < JsonLinker + self.file_type = :package_json + + private + + def link_dependencies + link_json('name', json["name"], &method(:package_url)) + link_json('license', &method(:license_url)) + link_json(%w[homepage url], URL_REGEX, &:itself) + + link_packages + end + + def link_packages + link_packages_at_key("dependencies", &method(:package_url)) + link_packages_at_key("devDependencies", &method(:package_url)) + end + + def link_packages_at_key(key, &url_proc) + dependencies = json[key] + return unless dependencies + + dependencies.each do |name, version| + link_json(name, version, link: :key, &url_proc) + + link_json(name) do |value| + case value + when /\A#{URL_REGEX}\z/ + value + when /\A#{REPO_REGEX}\z/ + github_url(value) + end + end + end + end + + def package_url(name) + "https://npmjs.com/package/#{name}" + end + end + end +end diff --git a/lib/gitlab/dependency_linker/podfile_linker.rb b/lib/gitlab/dependency_linker/podfile_linker.rb new file mode 100644 index 00000000000..60ad166ea17 --- /dev/null +++ b/lib/gitlab/dependency_linker/podfile_linker.rb @@ -0,0 +1,15 @@ +module Gitlab + module DependencyLinker + class PodfileLinker < GemfileLinker + include Cocoapods + + self.file_type = :podfile + + private + + def link_packages + link_method_call('pod', &method(:package_url)) + end + end + end +end diff --git a/lib/gitlab/dependency_linker/podspec_json_linker.rb b/lib/gitlab/dependency_linker/podspec_json_linker.rb new file mode 100644 index 00000000000..d82237ed3f1 --- /dev/null +++ b/lib/gitlab/dependency_linker/podspec_json_linker.rb @@ -0,0 +1,32 @@ +module Gitlab + module DependencyLinker + class PodspecJsonLinker < JsonLinker + include Cocoapods + + self.file_type = :podspec_json + + private + + def link_dependencies + link_json('name', json["name"], &method(:package_url)) + link_json('license', &method(:license_url)) + link_json(%w[homepage git], URL_REGEX, &:itself) + + link_packages_at_key("dependencies", &method(:package_url)) + + json["subspecs"]&.each do |subspec| + link_packages_at_key("dependencies", subspec, &method(:package_url)) + end + end + + def link_packages_at_key(key, root = json, &url_proc) + dependencies = root[key] + return unless dependencies + + dependencies.each do |name, _| + link_regex(/"(?<name>#{Regexp.escape(name)})":\s*\[/, &url_proc) + end + end + end + end +end diff --git a/lib/gitlab/dependency_linker/podspec_linker.rb b/lib/gitlab/dependency_linker/podspec_linker.rb new file mode 100644 index 00000000000..a52c7a02439 --- /dev/null +++ b/lib/gitlab/dependency_linker/podspec_linker.rb @@ -0,0 +1,24 @@ +module Gitlab + module DependencyLinker + class PodspecLinker < MethodLinker + include Cocoapods + + STRING_REGEX = /['"](?<name>[^'"]+)['"]/.freeze + + self.file_type = :podspec + + private + + def link_dependencies + link_method_call('homepage', URL_REGEX, &:itself) + + link_regex(%r{(git:|:git\s*=>)\s*['"](?<name>#{URL_REGEX})['"]}, &:itself) + + link_method_call('license', &method(:license_url)) + link_regex(/license\s*=\s*\{\s*(type:|:type\s*=>)\s*#{STRING_REGEX}/, &method(:license_url)) + + link_method_call(%w[name dependency], &method(:package_url)) + end + end + end +end diff --git a/lib/gitlab/dependency_linker/requirements_txt_linker.rb b/lib/gitlab/dependency_linker/requirements_txt_linker.rb new file mode 100644 index 00000000000..2e197e5cd94 --- /dev/null +++ b/lib/gitlab/dependency_linker/requirements_txt_linker.rb @@ -0,0 +1,17 @@ +module Gitlab + module DependencyLinker + class RequirementsTxtLinker < BaseLinker + self.file_type = :requirements_txt + + private + + def link_dependencies + link_regex(/^(?<name>(?![a-z+]+:)[^#.-][^ ><=;\[]+)/) do |name| + "https://pypi.python.org/pypi/#{name}" + end + + link_regex(%r{^(?<name>https?://[^ ]+)}, &:itself) + end + end + end +end diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb index c6bf25b5874..2aef7fdaa35 100644 --- a/lib/gitlab/diff/file.rb +++ b/lib/gitlab/diff/file.rb @@ -1,16 +1,17 @@ module Gitlab module Diff class File - attr_reader :diff, :repository, :diff_refs + attr_reader :diff, :repository, :diff_refs, :fallback_diff_refs - delegate :new_file, :deleted_file, :renamed_file, - :old_path, :new_path, :a_mode, :b_mode, + delegate :new_file?, :deleted_file?, :renamed_file?, + :old_path, :new_path, :a_mode, :b_mode, :mode_changed?, :submodule?, :too_large?, :collapsed?, to: :diff, prefix: false - def initialize(diff, repository:, diff_refs: nil) + def initialize(diff, repository:, diff_refs: nil, fallback_diff_refs: nil) @diff = diff @repository = repository @diff_refs = diff_refs + @fallback_diff_refs = fallback_diff_refs end def position(line) @@ -49,24 +50,60 @@ module Gitlab line_code(line) if line end + def old_sha + diff_refs&.base_sha + end + + def new_sha + diff_refs&.head_sha + end + + def content_sha + return old_content_sha if deleted_file? + return @content_sha if defined?(@content_sha) + + refs = diff_refs || fallback_diff_refs + @content_sha = refs&.head_sha + end + def content_commit - return unless diff_refs + return @content_commit if defined?(@content_commit) + + sha = content_sha + @content_commit = repository.commit(sha) if sha + end + + def old_content_sha + return if new_file? + return @old_content_sha if defined?(@old_content_sha) - repository.commit(deleted_file ? old_ref : new_ref) + refs = diff_refs || fallback_diff_refs + @old_content_sha = refs&.base_sha end def old_content_commit - return unless diff_refs + return @old_content_commit if defined?(@old_content_commit) - repository.commit(old_ref) + sha = old_content_sha + @old_content_commit = repository.commit(sha) if sha end - def old_ref - diff_refs.try(:base_sha) + def blob + return @blob if defined?(@blob) + + sha = content_sha + return @blob = nil unless sha + + repository.blob_at(sha, file_path) end - def new_ref - diff_refs.try(:head_sha) + def old_blob + return @old_blob if defined?(@old_blob) + + sha = old_content_sha + return @old_blob = nil unless sha + + @old_blob = repository.blob_at(sha, old_path) end attr_writer :highlighted_diff_lines @@ -85,10 +122,6 @@ module Gitlab @parallel_diff_lines ||= Gitlab::Diff::ParallelDiff.new(self).parallelize end - def mode_changed? - a_mode && b_mode && a_mode != b_mode - end - def raw_diff diff.diff.to_s end @@ -117,20 +150,8 @@ module Gitlab diff_lines.count(&:removed?) end - def old_blob(commit = old_content_commit) - return unless commit - - repository.blob_at(commit.id, old_path) - end - - def blob(commit = content_commit) - return unless commit - - repository.blob_at(commit.id, file_path) - end - def file_identifier - "#{file_path}-#{new_file}-#{deleted_file}-#{renamed_file}" + "#{file_path}-#{new_file?}-#{deleted_file?}-#{renamed_file?}" end end end diff --git a/lib/gitlab/diff/file_collection/base.rb b/lib/gitlab/diff/file_collection/base.rb index 7c32adc6ce7..a6007ebf531 100644 --- a/lib/gitlab/diff/file_collection/base.rb +++ b/lib/gitlab/diff/file_collection/base.rb @@ -2,22 +2,23 @@ module Gitlab module Diff module FileCollection class Base - attr_reader :project, :diff_options, :diff_view, :diff_refs + attr_reader :project, :diff_options, :diff_refs, :fallback_diff_refs delegate :count, :size, :real_size, to: :diff_files def self.default_options - ::Commit.max_diff_options.merge(ignore_whitespace_change: false, no_collapse: false) + ::Commit.max_diff_options.merge(ignore_whitespace_change: false, expanded: false) end - def initialize(diffable, project:, diff_options: nil, diff_refs: nil) + def initialize(diffable, project:, diff_options: nil, diff_refs: nil, fallback_diff_refs: nil) diff_options = self.class.default_options.merge(diff_options || {}) - @diffable = diffable - @diffs = diffable.raw_diffs(diff_options) - @project = project + @diffable = diffable + @diffs = diffable.raw_diffs(diff_options) + @project = project @diff_options = diff_options - @diff_refs = diff_refs + @diff_refs = diff_refs + @fallback_diff_refs = fallback_diff_refs end def diff_files @@ -35,7 +36,7 @@ module Gitlab private def decorate_diff!(diff) - Gitlab::Diff::File.new(diff, repository: project.repository, diff_refs: diff_refs) + Gitlab::Diff::File.new(diff, repository: project.repository, diff_refs: diff_refs, fallback_diff_refs: fallback_diff_refs) end end end diff --git a/lib/gitlab/diff/file_collection/merge_request_diff.rb b/lib/gitlab/diff/file_collection/merge_request_diff.rb index 0bd226ef050..9a58b500a2c 100644 --- a/lib/gitlab/diff/file_collection/merge_request_diff.rb +++ b/lib/gitlab/diff/file_collection/merge_request_diff.rb @@ -8,7 +8,8 @@ module Gitlab super(merge_request_diff, project: merge_request_diff.project, diff_options: diff_options, - diff_refs: merge_request_diff.diff_refs) + diff_refs: merge_request_diff.diff_refs, + fallback_diff_refs: merge_request_diff.fallback_diff_refs) end def diff_files diff --git a/lib/gitlab/diff/highlight.rb b/lib/gitlab/diff/highlight.rb index 7db896522a9..ed2f541977a 100644 --- a/lib/gitlab/diff/highlight.rb +++ b/lib/gitlab/diff/highlight.rb @@ -3,7 +3,7 @@ module Gitlab class Highlight attr_reader :diff_file, :diff_lines, :raw_lines, :repository - delegate :old_path, :new_path, :old_ref, :new_ref, to: :diff_file, prefix: :diff + delegate :old_path, :new_path, :old_sha, :new_sha, to: :diff_file, prefix: :diff def initialize(diff_lines, repository: nil) @repository = repository @@ -61,12 +61,12 @@ module Gitlab def old_lines return unless diff_file - @old_lines ||= Gitlab::Highlight.highlight_lines(self.repository, diff_old_ref, diff_old_path) + @old_lines ||= Gitlab::Highlight.highlight_lines(self.repository, diff_old_sha, diff_old_path) end def new_lines return unless diff_file - @new_lines ||= Gitlab::Highlight.highlight_lines(self.repository, diff_new_ref, diff_new_path) + @new_lines ||= Gitlab::Highlight.highlight_lines(self.repository, diff_new_sha, diff_new_path) end end end diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb index 0a15c6d9358..bd52ae47e9f 100644 --- a/lib/gitlab/diff/line.rb +++ b/lib/gitlab/diff/line.rb @@ -59,6 +59,10 @@ module Gitlab type == 'match' end + def discussable? + !['match', 'new-nonewline', 'old-nonewline'].include?(type) + end + def as_json(opts = nil) { type: type, diff --git a/lib/gitlab/email/message/repository_push.rb b/lib/gitlab/email/message/repository_push.rb index 6c69cd9e6a9..ea035e33eff 100644 --- a/lib/gitlab/email/message/repository_push.rb +++ b/lib/gitlab/email/message/repository_push.rb @@ -42,7 +42,7 @@ module Gitlab return unless compare # This diff is more moderated in number of files and lines - @diffs ||= compare.diffs(max_files: 30, max_lines: 5000, no_collapse: true).diff_files + @diffs ||= compare.diffs(max_files: 30, max_lines: 5000, expanded: true).diff_files end def diffs_count diff --git a/lib/gitlab/encoding_helper.rb b/lib/gitlab/encoding_helper.rb new file mode 100644 index 00000000000..dbe28e6bb93 --- /dev/null +++ b/lib/gitlab/encoding_helper.rb @@ -0,0 +1,62 @@ +module Gitlab + module EncodingHelper + extend self + + # This threshold is carefully tweaked to prevent usage of encodings detected + # by CharlockHolmes with low confidence. If CharlockHolmes confidence is low, + # we're better off sticking with utf8 encoding. + # Reason: git diff can return strings with invalid utf8 byte sequences if it + # truncates a diff in the middle of a multibyte character. In this case + # CharlockHolmes will try to guess the encoding and will likely suggest an + # obscure encoding with low confidence. + # There is a lot more info with this merge request: + # https://gitlab.com/gitlab-org/gitlab_git/merge_requests/77#note_4754193 + ENCODING_CONFIDENCE_THRESHOLD = 40 + + def encode!(message) + return nil unless message.respond_to? :force_encoding + + # if message is utf-8 encoding, just return it + message.force_encoding("UTF-8") + return message if message.valid_encoding? + + # return message if message type is binary + detect = CharlockHolmes::EncodingDetector.detect(message) + return message.force_encoding("BINARY") if detect && detect[:type] == :binary + + # force detected encoding if we have sufficient confidence. + if detect && detect[:encoding] && detect[:confidence] > ENCODING_CONFIDENCE_THRESHOLD + message.force_encoding(detect[:encoding]) + end + + # encode and clean the bad chars + message.replace clean(message) + rescue + encoding = detect ? detect[:encoding] : "unknown" + "--broken encoding: #{encoding}" + end + + def encode_utf8(message) + detect = CharlockHolmes::EncodingDetector.detect(message) + if detect + begin + CharlockHolmes::Converter.convert(message, detect[:encoding], 'UTF-8') + rescue ArgumentError => e + Rails.logger.warn("Ignoring error converting #{detect[:encoding]} into UTF8: #{e.message}") + + '' + end + else + clean(message) + end + end + + private + + def clean(message) + message.encode("UTF-16BE", undef: :replace, invalid: :replace, replace: "") + .encode("UTF-8") + .gsub("\0".encode("UTF-8"), "") + end + end +end diff --git a/lib/gitlab/etag_caching/router.rb b/lib/gitlab/etag_caching/router.rb index 2b0e19b338b..ca49eda51fb 100644 --- a/lib/gitlab/etag_caching/router.rb +++ b/lib/gitlab/etag_caching/router.rb @@ -9,9 +9,11 @@ module Gitlab # - Ending in `noteable/issue/<id>/notes` for the `issue_notes` route # - Ending in `issues/id`/realtime_changes` for the `issue_title` route USED_IN_ROUTES = %w[noteable issue notes issues realtime_changes - commit pipelines merge_requests new].freeze - RESERVED_WORDS = Gitlab::Regex::ILLEGAL_PROJECT_PATH_WORDS - USED_IN_ROUTES - RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS) + commit pipelines merge_requests builds + new environments].freeze + RESERVED_WORDS = Gitlab::PathRegex::ILLEGAL_PROJECT_PATH_WORDS - USED_IN_ROUTES + RESERVED_WORDS_REGEX = Regexp.union(*RESERVED_WORDS.map(&Regexp.method(:escape))) + ROUTES = [ Gitlab::EtagCaching::Router::Route.new( %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/noteable/issue/\d+/notes\z), @@ -40,6 +42,14 @@ module Gitlab Gitlab::EtagCaching::Router::Route.new( %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/pipelines/\d+\.json\z), 'project_pipeline' + ), + Gitlab::EtagCaching::Router::Route.new( + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/builds/\d+\.json\z), + 'project_build' + ), + Gitlab::EtagCaching::Router::Route.new( + %r(^(?!.*(#{RESERVED_WORDS_REGEX})).*/environments\.json\z), + 'environments' ) ].freeze diff --git a/lib/gitlab/git/blame.rb b/lib/gitlab/git/blame.rb index 58193391926..66829a03c2e 100644 --- a/lib/gitlab/git/blame.rb +++ b/lib/gitlab/git/blame.rb @@ -1,7 +1,7 @@ module Gitlab module Git class Blame - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper attr_reader :lines, :blames diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb index c1b31618e0d..d60e607b02b 100644 --- a/lib/gitlab/git/blob.rb +++ b/lib/gitlab/git/blob.rb @@ -2,7 +2,7 @@ module Gitlab module Git class Blob include Linguist::BlobHelper - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper # This number is the maximum amount of data that we want to display to # the user. We load as much as we can for encoding detection @@ -88,6 +88,7 @@ module Gitlab new( id: blob_entry[:oid], name: blob_entry[:name], + size: 0, data: '', path: path, commit_id: sha diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index 297531db4cc..bb04731f08c 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -2,7 +2,7 @@ module Gitlab module Git class Commit - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper attr_accessor :raw_commit, :head, :refs diff --git a/lib/gitlab/git/diff.rb b/lib/gitlab/git/diff.rb index 31d1b66b4f7..7e21994a084 100644 --- a/lib/gitlab/git/diff.rb +++ b/lib/gitlab/git/diff.rb @@ -3,7 +3,7 @@ module Gitlab module Git class Diff TimeoutError = Class.new(StandardError) - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper # Diff properties attr_accessor :old_path, :new_path, :a_mode, :b_mode, :diff @@ -11,13 +11,20 @@ module Gitlab # Stats properties attr_accessor :new_file, :renamed_file, :deleted_file + alias_method :new_file?, :new_file + alias_method :deleted_file?, :deleted_file + alias_method :renamed_file?, :renamed_file + + attr_accessor :expanded + + # We need this accessor because of `to_hash` and `init_from_hash` attr_accessor :too_large # The maximum size of a diff to display. - DIFF_SIZE_LIMIT = 102400 # 100 KB + SIZE_LIMIT = 100.kilobytes # The maximum size before a diff is collapsed. - DIFF_COLLAPSE_LIMIT = 10240 # 10 KB + COLLAPSE_LIMIT = 10.kilobytes class << self def between(repo, head, base, options = {}, *paths) @@ -148,7 +155,7 @@ module Gitlab :include_untracked_content, :skip_binary_check, :include_typechange, :include_typechange_trees, :ignore_filemode, :recurse_ignored_dirs, :paths, - :max_files, :max_lines, :all_diffs, :no_collapse] + :max_files, :max_lines, :limits, :expanded] if default_options actual_defaults = default_options.dup @@ -173,16 +180,18 @@ module Gitlab end end - def initialize(raw_diff, collapse: false) + def initialize(raw_diff, expanded: true) + @expanded = expanded + case raw_diff when Hash init_from_hash(raw_diff) - prune_diff_if_eligible(collapse) + prune_diff_if_eligible when Rugged::Patch, Rugged::Diff::Delta - init_from_rugged(raw_diff, collapse: collapse) - when Gitaly::CommitDiffResponse + init_from_rugged(raw_diff) + when Gitlab::GitalyClient::Diff init_from_gitaly(raw_diff) - prune_diff_if_eligible(collapse) + prune_diff_if_eligible when Gitaly::CommitDelta init_from_gitaly(raw_diff) when nil @@ -208,6 +217,10 @@ module Gitlab hash end + def mode_changed? + a_mode && b_mode && a_mode != b_mode + end + def submodule? a_mode == '160000' || b_mode == '160000' end @@ -218,17 +231,13 @@ module Gitlab def too_large? if @too_large.nil? - @too_large = @diff.bytesize >= DIFF_SIZE_LIMIT + @too_large = @diff.bytesize >= SIZE_LIMIT else @too_large end end - def collapsible? - @diff.bytesize >= DIFF_COLLAPSE_LIMIT - end - - def prune_large_diff! + def too_large! @diff = '' @line_count = 0 @too_large = true @@ -236,10 +245,11 @@ module Gitlab def collapsed? return @collapsed if defined?(@collapsed) - false + + @collapsed = !expanded && @diff.bytesize >= COLLAPSE_LIMIT end - def prune_collapsed_diff! + def collapse! @diff = '' @line_count = 0 @collapsed = true @@ -247,9 +257,9 @@ module Gitlab private - def init_from_rugged(rugged, collapse: false) + def init_from_rugged(rugged) if rugged.is_a?(Rugged::Patch) - init_from_rugged_patch(rugged, collapse: collapse) + init_from_rugged_patch(rugged) d = rugged.delta else d = rugged @@ -264,10 +274,10 @@ module Gitlab @deleted_file = d.deleted? end - def init_from_rugged_patch(patch, collapse: false) + def init_from_rugged_patch(patch) # Don't bother initializing diffs that are too large. If a diff is # binary we're not going to display anything so we skip the size check. - return if !patch.delta.binary? && prune_large_patch(patch, collapse) + return if !patch.delta.binary? && prune_large_patch(patch) @diff = encode!(strip_diff_headers(patch.to_s)) end @@ -280,40 +290,43 @@ module Gitlab end end - def init_from_gitaly(msg) - @diff = msg.raw_chunks.join if msg.respond_to?(:raw_chunks) - @new_path = encode!(msg.to_path.dup) - @old_path = encode!(msg.from_path.dup) - @a_mode = msg.old_mode.to_s(8) - @b_mode = msg.new_mode.to_s(8) - @new_file = msg.from_id == BLANK_SHA - @renamed_file = msg.from_path != msg.to_path - @deleted_file = msg.to_id == BLANK_SHA + def init_from_gitaly(diff) + @diff = diff.patch if diff.respond_to?(:patch) + @new_path = encode!(diff.to_path.dup) + @old_path = encode!(diff.from_path.dup) + @a_mode = diff.old_mode.to_s(8) + @b_mode = diff.new_mode.to_s(8) + @new_file = diff.from_id == BLANK_SHA + @renamed_file = diff.from_path != diff.to_path + @deleted_file = diff.to_id == BLANK_SHA end - def prune_diff_if_eligible(collapse = false) - prune_large_diff! if too_large? - prune_collapsed_diff! if collapse && collapsible? + def prune_diff_if_eligible + if too_large? + too_large! + elsif collapsed? + collapse! + end end # If the patch surpasses any of the diff limits it calls the appropiate # prune method and returns true. Otherwise returns false. - def prune_large_patch(patch, collapse) + def prune_large_patch(patch) size = 0 patch.each_hunk do |hunk| hunk.each_line do |line| size += line.content.bytesize - if size >= DIFF_SIZE_LIMIT - prune_large_diff! + if size >= SIZE_LIMIT + too_large! return true end end end - if collapse && size >= DIFF_COLLAPSE_LIMIT - prune_collapsed_diff! + if !expanded && size >= COLLAPSE_LIMIT + collapse! return true end diff --git a/lib/gitlab/git/diff_collection.rb b/lib/gitlab/git/diff_collection.rb index bcbad8ec829..334e06a6eca 100644 --- a/lib/gitlab/git/diff_collection.rb +++ b/lib/gitlab/git/diff_collection.rb @@ -9,32 +9,29 @@ module Gitlab @iterator = iterator @max_files = options.fetch(:max_files, DEFAULT_LIMITS[:max_files]) @max_lines = options.fetch(:max_lines, DEFAULT_LIMITS[:max_lines]) - @max_bytes = @max_files * 5120 # Average 5 KB per file + @max_bytes = @max_files * 5.kilobytes # Average 5 KB per file @safe_max_files = [@max_files, DEFAULT_LIMITS[:max_files]].min @safe_max_lines = [@max_lines, DEFAULT_LIMITS[:max_lines]].min - @safe_max_bytes = @safe_max_files * 5120 # Average 5 KB per file - @all_diffs = !!options.fetch(:all_diffs, false) - @no_collapse = !!options.fetch(:no_collapse, true) + @safe_max_bytes = @safe_max_files * 5.kilobytes # Average 5 KB per file + @enforce_limits = !!options.fetch(:limits, true) + @expanded = !!options.fetch(:expanded, true) @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,17 +57,17 @@ module Gitlab collection = each_with_index do |element, i| @array[i] = yield(element) end - @populated = true collection end + alias_method :to_ary, :to_a + private def populate! return if @populated each { nil } # force a loop through all diffs - @populated = true nil end @@ -79,33 +76,35 @@ 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 + if @enforce_limits && i >= @max_files @overflow = true break end - collapse = !@all_diffs && !@no_collapse + expanded = !@enforce_limits || @expanded - diff = Gitlab::Git::Diff.new(raw, collapse: collapse) + diff = Gitlab::Git::Diff.new(raw, expanded: expanded) - if collapse && over_safe_limits?(i) - diff.prune_collapsed_diff! + if !expanded && over_safe_limits?(i) + diff.collapse! end @line_count += diff.line_count @byte_count += diff.diff.bytesize - if !@all_diffs && (@line_count >= @max_lines || @byte_count >= @max_bytes) + if @enforce_limits && (@line_count >= @max_lines || @byte_count >= @max_bytes) # This last Diff instance pushes us over the lines limit. We stop and # discard it. @overflow = true @@ -113,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/git/encoding_helper.rb b/lib/gitlab/git/encoding_helper.rb deleted file mode 100644 index f918074cb14..00000000000 --- a/lib/gitlab/git/encoding_helper.rb +++ /dev/null @@ -1,64 +0,0 @@ -module Gitlab - module Git - module EncodingHelper - extend self - - # This threshold is carefully tweaked to prevent usage of encodings detected - # by CharlockHolmes with low confidence. If CharlockHolmes confidence is low, - # we're better off sticking with utf8 encoding. - # Reason: git diff can return strings with invalid utf8 byte sequences if it - # truncates a diff in the middle of a multibyte character. In this case - # CharlockHolmes will try to guess the encoding and will likely suggest an - # obscure encoding with low confidence. - # There is a lot more info with this merge request: - # https://gitlab.com/gitlab-org/gitlab_git/merge_requests/77#note_4754193 - ENCODING_CONFIDENCE_THRESHOLD = 40 - - def encode!(message) - return nil unless message.respond_to? :force_encoding - - # if message is utf-8 encoding, just return it - message.force_encoding("UTF-8") - return message if message.valid_encoding? - - # return message if message type is binary - detect = CharlockHolmes::EncodingDetector.detect(message) - return message.force_encoding("BINARY") if detect && detect[:type] == :binary - - # force detected encoding if we have sufficient confidence. - if detect && detect[:encoding] && detect[:confidence] > ENCODING_CONFIDENCE_THRESHOLD - message.force_encoding(detect[:encoding]) - end - - # encode and clean the bad chars - message.replace clean(message) - rescue - encoding = detect ? detect[:encoding] : "unknown" - "--broken encoding: #{encoding}" - end - - def encode_utf8(message) - detect = CharlockHolmes::EncodingDetector.detect(message) - if detect - begin - CharlockHolmes::Converter.convert(message, detect[:encoding], 'UTF-8') - rescue ArgumentError => e - Rails.logger.warn("Ignoring error converting #{detect[:encoding]} into UTF8: #{e.message}") - - '' - end - else - clean(message) - end - end - - private - - def clean(message) - message.encode("UTF-16BE", undef: :replace, invalid: :replace, replace: "") - .encode("UTF-8") - .gsub("\0".encode("UTF-8"), "") - end - end - end -end diff --git a/lib/gitlab/git/ref.rb b/lib/gitlab/git/ref.rb index 37ef6836742..ebf7393dc61 100644 --- a/lib/gitlab/git/ref.rb +++ b/lib/gitlab/git/ref.rb @@ -1,7 +1,7 @@ module Gitlab module Git class Ref - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper # Branch or tag name # without "refs/tags|heads" prefix diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index b9f1ac144b6..9d6adbdb4ac 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -1006,31 +1006,39 @@ module Gitlab # Parses the contents of a .gitmodules file and returns a hash of # submodule information. def parse_gitmodules(commit, content) - results = {} + modules = {} - current = "" - content.split("\n").each do |txt| - if txt =~ /^\s*\[/ - current = txt.match(/(?<=").*(?=")/)[0] - results[current] = {} - else - next unless results[current] - match_data = txt.match(/(\w+)\s*=\s*(.*)/) - next unless match_data - target = match_data[2].chomp - results[current][match_data[1]] = target + name = nil + content.each_line do |line| + case line.strip + when /\A\[submodule "(?<name>[^"]+)"\]\z/ # Submodule header + name = $~[:name] + modules[name] = {} + when /\A(?<key>\w+)\s*=\s*(?<value>.*)\z/ # Key/value pair + key = $~[:key] + value = $~[:value].chomp + + next unless name && modules[name] + + modules[name][key] = value - if match_data[1] == "path" + if key == 'path' begin - results[current]["id"] = blob_content(commit, target) + modules[name]['id'] = blob_content(commit, value) rescue InvalidBlobName - results.delete(current) + # The current entry is invalid + modules.delete(name) + name = nil end end + when /\A#/ # Comment + next + else # Invalid line + name = nil end end - results + modules end # Returns true if +commit+ introduced changes to +path+, using commit @@ -1086,7 +1094,12 @@ module Gitlab elsif tmp_entry.nil? return nil else - tmp_entry = rugged.lookup(tmp_entry[:oid]) + begin + tmp_entry = rugged.lookup(tmp_entry[:oid]) + rescue Rugged::OdbError, Rugged::InvalidError, Rugged::ReferenceError + return nil + end + return nil unless tmp_entry.type == :tree tmp_entry = tmp_entry[dir] end diff --git a/lib/gitlab/git/tree.rb b/lib/gitlab/git/tree.rb index d41256d9a84..b9afa05c819 100644 --- a/lib/gitlab/git/tree.rb +++ b/lib/gitlab/git/tree.rb @@ -1,7 +1,7 @@ module Gitlab module Git class Tree - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper attr_accessor :id, :root_id, :name, :path, :type, :mode, :commit_id, :submodule_url diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index 72466700c05..2343446bf22 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -2,6 +2,12 @@ require 'gitaly' module Gitlab module GitalyClient + module MigrationStatus + DISABLED = 1 + OPT_IN = 2 + OPT_OUT = 3 + end + SERVER_VERSION_FILE = 'GITALY_SERVER_VERSION'.freeze MUTEX = Mutex.new @@ -46,8 +52,20 @@ module Gitlab Gitlab.config.gitaly.enabled end - def self.feature_enabled?(feature) - enabled? && ENV["GITALY_#{feature.upcase}"] == '1' + def self.feature_enabled?(feature, status: MigrationStatus::OPT_IN) + return false if !enabled? || status == MigrationStatus::DISABLED + + feature = Feature.get("gitaly_#{feature}") + + # If the feature hasn't been set, turn it on if it's opt-out + return status == MigrationStatus::OPT_OUT unless Feature.persisted?(feature) + + if feature.percentage_of_time_value > 0 + # Probabilistically enable this feature + return Random.rand() * 100 < feature.percentage_of_time_value + end + + feature.enabled? end def self.migrate(feature) diff --git a/lib/gitlab/gitaly_client/commit.rb b/lib/gitlab/gitaly_client/commit.rb index 4491903d788..ba3da781dad 100644 --- a/lib/gitlab/gitaly_client/commit.rb +++ b/lib/gitlab/gitaly_client/commit.rb @@ -26,7 +26,7 @@ module Gitlab request_params[:ignore_whitespace_change] = options.fetch(:ignore_whitespace_change, false) response = diff_service_stub.commit_diff(Gitaly::CommitDiffRequest.new(request_params)) - Gitlab::Git::DiffCollection.new(response, options) + Gitlab::Git::DiffCollection.new(GitalyClient::DiffStitcher.new(response), options) end def commit_deltas(commit) diff --git a/lib/gitlab/gitaly_client/diff.rb b/lib/gitlab/gitaly_client/diff.rb new file mode 100644 index 00000000000..1e117b7e74a --- /dev/null +++ b/lib/gitlab/gitaly_client/diff.rb @@ -0,0 +1,21 @@ +module Gitlab + module GitalyClient + class Diff + FIELDS = %i(from_path to_path old_mode new_mode from_id to_id patch).freeze + + attr_accessor(*FIELDS) + + def initialize(params) + params.each do |key, val| + public_send(:"#{key}=", val) + end + end + + def ==(other) + FIELDS.all? do |field| + public_send(field) == other.public_send(field) + end + end + end + end +end diff --git a/lib/gitlab/gitaly_client/diff_stitcher.rb b/lib/gitlab/gitaly_client/diff_stitcher.rb new file mode 100644 index 00000000000..d84e8d752dc --- /dev/null +++ b/lib/gitlab/gitaly_client/diff_stitcher.rb @@ -0,0 +1,31 @@ +module Gitlab + module GitalyClient + class DiffStitcher + include Enumerable + + def initialize(rpc_response) + @rpc_response = rpc_response + end + + def each + current_diff = nil + + @rpc_response.each do |diff_msg| + if current_diff.nil? + diff_params = diff_msg.to_h.slice(*GitalyClient::Diff::FIELDS) + diff_params[:patch] = diff_msg.raw_patch_data + + current_diff = GitalyClient::Diff.new(diff_params) + else + current_diff.patch += diff_msg.raw_patch_data + end + + if diff_msg.end_of_patch + yield current_diff + current_diff = nil + end + end + end + end + end +end diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 21f2e6b6970..319633656ff 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/google_code_import/client.rb b/lib/gitlab/google_code_import/client.rb index 890bd9a3554..b1dbf554e41 100644 --- a/lib/gitlab/google_code_import/client.rb +++ b/lib/gitlab/google_code_import/client.rb @@ -14,7 +14,7 @@ module Gitlab end def valid? - raw_data.is_a?(Hash) && raw_data["kind"] == "projecthosting#user" && raw_data.has_key?("projects") + raw_data.is_a?(Hash) && raw_data["kind"] == "projecthosting#user" && raw_data.key?("projects") end def repos diff --git a/lib/gitlab/google_code_import/importer.rb b/lib/gitlab/google_code_import/importer.rb index 1b43440673c..ab38c0c3e34 100644 --- a/lib/gitlab/google_code_import/importer.rb +++ b/lib/gitlab/google_code_import/importer.rb @@ -95,7 +95,7 @@ module Gitlab labels = import_issue_labels(raw_issue) assignee_id = nil - if raw_issue.has_key?("owner") + if raw_issue.key?("owner") username = user_map[raw_issue["owner"]["name"]] if username.start_with?("@") @@ -144,7 +144,7 @@ module Gitlab def import_issue_comments(issue, comments) Note.transaction do while raw_comment = comments.shift - next if raw_comment.has_key?("deletedBy") + next if raw_comment.key?("deletedBy") content = format_content(raw_comment["content"]) updates = format_updates(raw_comment["updates"]) @@ -235,15 +235,15 @@ module Gitlab def format_updates(raw_updates) updates = [] - if raw_updates.has_key?("status") + if raw_updates.key?("status") updates << "*Status: #{raw_updates["status"]}*" end - if raw_updates.has_key?("owner") + if raw_updates.key?("owner") updates << "*Owner: #{user_map[raw_updates["owner"]]}*" end - if raw_updates.has_key?("cc") + if raw_updates.key?("cc") cc = raw_updates["cc"].map do |l| deleted = l.start_with?("-") l = l[1..-1] if deleted @@ -255,7 +255,7 @@ module Gitlab updates << "*Cc: #{cc.join(", ")}*" end - if raw_updates.has_key?("labels") + if raw_updates.key?("labels") labels = raw_updates["labels"].map do |l| deleted = l.start_with?("-") l = l[1..-1] if deleted @@ -267,11 +267,11 @@ module Gitlab updates << "*Labels: #{labels.join(", ")}*" end - if raw_updates.has_key?("mergedInto") + if raw_updates.key?("mergedInto") updates << "*Merged into: ##{raw_updates["mergedInto"]}*" end - if raw_updates.has_key?("blockedOn") + if raw_updates.key?("blockedOn") blocked_ons = raw_updates["blockedOn"].map do |raw_blocked_on| format_blocking_updates(raw_blocked_on) end @@ -279,7 +279,7 @@ module Gitlab updates << "*Blocked on: #{blocked_ons.join(", ")}*" end - if raw_updates.has_key?("blocking") + if raw_updates.key?("blocking") blockings = raw_updates["blocking"].map do |raw_blocked_on| format_blocking_updates(raw_blocked_on) end diff --git a/lib/gitlab/group_hierarchy.rb b/lib/gitlab/group_hierarchy.rb new file mode 100644 index 00000000000..e9d5d52cabb --- /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 df962d203b7..e78b7f22e03 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/i18n.rb b/lib/gitlab/i18n.rb index 3411516319f..5ab3eeb3aff 100644 --- a/lib/gitlab/i18n.rb +++ b/lib/gitlab/i18n.rb @@ -12,15 +12,36 @@ module Gitlab AVAILABLE_LANGUAGES.keys end - def set_locale(current_user) - requested_locale = current_user&.preferred_language || ::I18n.default_locale - locale = FastGettext.set_locale(requested_locale) - ::I18n.locale = locale + def locale + FastGettext.locale end - def reset_locale + def locale=(locale_string) + requested_locale = locale_string || ::I18n.default_locale + new_locale = FastGettext.set_locale(requested_locale) + ::I18n.locale = new_locale + end + + def use_default_locale FastGettext.set_locale(::I18n.default_locale) ::I18n.locale = ::I18n.default_locale end + + def with_locale(locale_string) + original_locale = locale + + self.locale = locale_string + yield + ensure + self.locale = original_locale + end + + def with_user_locale(user, &block) + with_locale(user&.preferred_language, &block) + end + + def with_default_locale(&block) + with_locale(::I18n.default_locale, &block) + end end end diff --git a/lib/gitlab/o_auth/provider.rb b/lib/gitlab/o_auth/provider.rb index 9ad7a38d505..ac9d66c836d 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/path_regex.rb b/lib/gitlab/path_regex.rb new file mode 100644 index 00000000000..9ff6829cd49 --- /dev/null +++ b/lib/gitlab/path_regex.rb @@ -0,0 +1,265 @@ +module Gitlab + module PathRegex + extend self + + # All routes that appear on the top level must be listed here. + # This will make sure that groups cannot be created with these names + # as these routes would be masked by the paths already in place. + # + # Example: + # /api/api-project + # + # the path `api` shouldn't be allowed because it would be masked by `api/*` + # + TOP_LEVEL_ROUTES = %w[ + - + .well-known + abuse_reports + admin + all + api + assets + autocomplete + ci + dashboard + explore + files + groups + health_check + help + hooks + import + invites + issues + jwt + koding + member + merge_requests + new + notes + notification_settings + oauth + profile + projects + public + repository + robots.txt + s + search + sent_notifications + services + snippets + teams + u + unicorn_test + unsubscribes + uploads + users + ].freeze + + # This list should contain all words following `/*namespace_id/:project_id` in + # routes that contain a second wildcard. + # + # Example: + # /*namespace_id/:project_id/badges/*ref/build + # + # If `badges` was allowed as a project/group name, we would not be able to access the + # `badges` route for those projects: + # + # Consider a namespace with path `foo/bar` and a project called `badges`. + # The route to the build badge would then be `/foo/bar/badges/badges/master/build.svg` + # + # When accessing this path the route would be matched to the `badges` path + # with the following params: + # - namespace_id: `foo` + # - project_id: `bar` + # - ref: `badges/master` + # + # Failing to find the project, this would result in a 404. + # + # By rejecting `badges` the router can _count_ on the fact that `badges` will + # be preceded by the `namespace/project`. + PROJECT_WILDCARD_ROUTES = %w[ + - + badges + blame + blob + builds + commits + create + create_dir + edit + environments/folders + files + find_file + gitlab-lfs/objects + info/lfs/objects + new + preview + raw + refs + tree + update + wikis + ].freeze + + # These are all the paths that follow `/groups/*id/ or `/groups/*group_id` + # We need to reject these because we have a `/groups/*id` page that is the same + # as the `/*id`. + # + # If we would allow a subgroup to be created with the name `activity` then + # this group would not be accessible through `/groups/parent/activity` since + # this would map to the activity-page of its parent. + GROUP_ROUTES = %w[ + activity + analytics + audit_events + avatar + edit + group_members + hooks + issues + labels + ldap + ldap_group_links + merge_requests + milestones + notification_setting + pipeline_quota + projects + subgroups + ].freeze + + ILLEGAL_PROJECT_PATH_WORDS = PROJECT_WILDCARD_ROUTES + ILLEGAL_GROUP_PATH_WORDS = (PROJECT_WILDCARD_ROUTES | GROUP_ROUTES).freeze + + # The namespace regex is used in JavaScript to validate usernames in the "Register" form. However, Javascript + # does not support the negative lookbehind assertion (?<!) that disallows usernames ending in `.git` and `.atom`. + # Since this is a non-trivial problem to solve in Javascript (heavily complicate the regex, modify view code to + # allow non-regex validations, etc), `NAMESPACE_FORMAT_REGEX_JS` serves as a Javascript-compatible version of + # `NAMESPACE_FORMAT_REGEX`, with the negative lookbehind assertion removed. This means that the client-side validation + # will pass for usernames ending in `.atom` and `.git`, but will be caught by the server-side validation. + PATH_REGEX_STR = '[a-zA-Z0-9_\.][a-zA-Z0-9_\-\.]*'.freeze + NAMESPACE_FORMAT_REGEX_JS = PATH_REGEX_STR + '[a-zA-Z0-9_\-]|[a-zA-Z0-9_]'.freeze + + NO_SUFFIX_REGEX = /(?<!\.git|\.atom)/.freeze + NAMESPACE_FORMAT_REGEX = /(?:#{NAMESPACE_FORMAT_REGEX_JS})#{NO_SUFFIX_REGEX}/.freeze + PROJECT_PATH_FORMAT_REGEX = /(?:#{PATH_REGEX_STR})#{NO_SUFFIX_REGEX}/.freeze + FULL_NAMESPACE_FORMAT_REGEX = %r{(#{NAMESPACE_FORMAT_REGEX}/)*#{NAMESPACE_FORMAT_REGEX}}.freeze + + def root_namespace_route_regex + @root_namespace_route_regex ||= begin + illegal_words = Regexp.new(Regexp.union(TOP_LEVEL_ROUTES).source, Regexp::IGNORECASE) + + single_line_regexp %r{ + (?!(#{illegal_words})/) + #{NAMESPACE_FORMAT_REGEX} + }x + end + end + + def full_namespace_route_regex + @full_namespace_route_regex ||= begin + illegal_words = Regexp.new(Regexp.union(ILLEGAL_GROUP_PATH_WORDS).source, Regexp::IGNORECASE) + + single_line_regexp %r{ + #{root_namespace_route_regex} + (?: + / + (?!#{illegal_words}/) + #{NAMESPACE_FORMAT_REGEX} + )* + }x + end + end + + def project_route_regex + @project_route_regex ||= begin + illegal_words = Regexp.new(Regexp.union(ILLEGAL_PROJECT_PATH_WORDS).source, Regexp::IGNORECASE) + + single_line_regexp %r{ + (?!(#{illegal_words})/) + #{PROJECT_PATH_FORMAT_REGEX} + }x + end + end + + def project_git_route_regex + @project_git_route_regex ||= /#{project_route_regex}\.git/.freeze + end + + def root_namespace_path_regex + @root_namespace_path_regex ||= %r{\A#{root_namespace_route_regex}/\z} + end + + def full_namespace_path_regex + @full_namespace_path_regex ||= %r{\A#{full_namespace_route_regex}/\z} + end + + def project_path_regex + @project_path_regex ||= %r{\A#{project_route_regex}/\z} + end + + def full_project_path_regex + @full_project_path_regex ||= %r{\A#{full_namespace_route_regex}/#{project_route_regex}/\z} + end + + def full_namespace_format_regex + @namespace_format_regex ||= /A#{FULL_NAMESPACE_FORMAT_REGEX}\z/.freeze + end + + def namespace_format_regex + @namespace_format_regex ||= /\A#{NAMESPACE_FORMAT_REGEX}\z/.freeze + end + + def namespace_format_message + "can contain only letters, digits, '_', '-' and '.'. " \ + "Cannot start with '-' or end in '.', '.git' or '.atom'." \ + end + + def project_path_format_regex + @project_path_format_regex ||= /\A#{PROJECT_PATH_FORMAT_REGEX}\z/.freeze + end + + def project_path_format_message + "can contain only letters, digits, '_', '-' and '.'. " \ + "Cannot start with '-', end in '.git' or end in '.atom'" \ + end + + def archive_formats_regex + # |zip|tar| tar.gz | tar.bz2 | + @archive_formats_regex ||= /(zip|tar|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/.freeze + end + + def git_reference_regex + # Valid git ref regex, see: + # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html + + @git_reference_regex ||= single_line_regexp %r{ + (?! + (?# doesn't begins with) + \/| (?# rule #6) + (?# doesn't contain) + .*(?: + [\/.]\.| (?# rule #1,3) + \/\/| (?# rule #6) + @\{| (?# rule #8) + \\ (?# rule #9) + ) + ) + [^\000-\040\177~^:?*\[]+ (?# rule #4-5) + (?# doesn't end with) + (?<!\.lock) (?# rule #1) + (?<![\/.]) (?# rule #6-7) + }x + end + + private + + def single_line_regexp(regex) + # Turns a multiline extended regexp into a single line one, + # beacuse `rake routes` breaks on multiline regexes. + Regexp.new(regex.source.gsub(/\(\?#.+?\)/, '').gsub(/\s*/, ''), regex.options ^ Regexp::EXTENDED).freeze + end + end +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 00000000000..bb0df1e3dad --- /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 00000000000..627e8c5fba2 --- /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/regex.rb b/lib/gitlab/regex.rb index f609850f8fa..e4d2a992470 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -2,203 +2,6 @@ module Gitlab module Regex extend self - # All routes that appear on the top level must be listed here. - # This will make sure that groups cannot be created with these names - # as these routes would be masked by the paths already in place. - # - # Example: - # /api/api-project - # - # the path `api` shouldn't be allowed because it would be masked by `api/*` - # - TOP_LEVEL_ROUTES = %w[ - - - .well-known - abuse_reports - admin - all - api - assets - autocomplete - ci - dashboard - explore - files - groups - health_check - help - hooks - import - invites - issues - jwt - koding - member - merge_requests - new - notes - notification_settings - oauth - profile - projects - public - repository - robots.txt - s - search - sent_notifications - services - snippets - teams - u - unicorn_test - unsubscribes - uploads - users - ].freeze - - # This list should contain all words following `/*namespace_id/:project_id` in - # routes that contain a second wildcard. - # - # Example: - # /*namespace_id/:project_id/badges/*ref/build - # - # If `badges` was allowed as a project/group name, we would not be able to access the - # `badges` route for those projects: - # - # Consider a namespace with path `foo/bar` and a project called `badges`. - # The route to the build badge would then be `/foo/bar/badges/badges/master/build.svg` - # - # When accessing this path the route would be matched to the `badges` path - # with the following params: - # - namespace_id: `foo` - # - project_id: `bar` - # - ref: `badges/master` - # - # Failing to find the project, this would result in a 404. - # - # By rejecting `badges` the router can _count_ on the fact that `badges` will - # be preceded by the `namespace/project`. - PROJECT_WILDCARD_ROUTES = %w[ - badges - blame - blob - builds - commits - create - create_dir - edit - environments/folders - files - find_file - gitlab-lfs/objects - info/lfs/objects - new - preview - raw - refs - tree - update - wikis - ].freeze - - # These are all the paths that follow `/groups/*id/ or `/groups/*group_id` - # We need to reject these because we have a `/groups/*id` page that is the same - # as the `/*id`. - # - # If we would allow a subgroup to be created with the name `activity` then - # this group would not be accessible through `/groups/parent/activity` since - # this would map to the activity-page of its parent. - GROUP_ROUTES = %w[ - activity - analytics - audit_events - avatar - edit - group_members - hooks - issues - labels - ldap - ldap_group_links - merge_requests - milestones - notification_setting - pipeline_quota - projects - subgroups - ].freeze - - ILLEGAL_PROJECT_PATH_WORDS = PROJECT_WILDCARD_ROUTES - ILLEGAL_GROUP_PATH_WORDS = (PROJECT_WILDCARD_ROUTES | GROUP_ROUTES).freeze - - # The namespace regex is used in Javascript to validate usernames in the "Register" form. However, Javascript - # does not support the negative lookbehind assertion (?<!) that disallows usernames ending in `.git` and `.atom`. - # Since this is a non-trivial problem to solve in Javascript (heavily complicate the regex, modify view code to - # allow non-regex validatiions, etc), `NAMESPACE_REGEX_STR_JS` serves as a Javascript-compatible version of - # `NAMESPACE_REGEX_STR`, with the negative lookbehind assertion removed. This means that the client-side validation - # will pass for usernames ending in `.atom` and `.git`, but will be caught by the server-side validation. - PATH_REGEX_STR = '[a-zA-Z0-9_\.][a-zA-Z0-9_\-\.]*'.freeze - NAMESPACE_REGEX_STR_JS = PATH_REGEX_STR + '[a-zA-Z0-9_\-]|[a-zA-Z0-9_]'.freeze - NO_SUFFIX_REGEX_STR = '(?<!\.git|\.atom)'.freeze - NAMESPACE_REGEX_STR = "(?:#{NAMESPACE_REGEX_STR_JS})#{NO_SUFFIX_REGEX_STR}".freeze - PROJECT_REGEX_STR = "(?:#{PATH_REGEX_STR})#{NO_SUFFIX_REGEX_STR}".freeze - - # Same as NAMESPACE_REGEX_STR but allows `/` in the path. - # So `group/subgroup` will match this regex but not NAMESPACE_REGEX_STR - FULL_NAMESPACE_REGEX_STR = "(?:#{NAMESPACE_REGEX_STR}/)*#{NAMESPACE_REGEX_STR}".freeze - - def root_namespace_route_regex - @root_namespace_route_regex ||= begin - illegal_words = Regexp.new(Regexp.union(TOP_LEVEL_ROUTES).source, Regexp::IGNORECASE) - - single_line_regexp %r{ - (?!(#{illegal_words})/) - #{NAMESPACE_REGEX_STR} - }x - end - end - - def root_namespace_path_regex - @root_namespace_path_regex ||= %r{\A#{root_namespace_route_regex}/\z} - end - - def full_namespace_path_regex - @full_namespace_path_regex ||= %r{\A#{namespace_route_regex}/\z} - end - - def full_project_path_regex - @full_project_path_regex ||= %r{\A#{namespace_route_regex}/#{project_route_regex}/\z} - end - - def namespace_regex - @namespace_regex ||= /\A#{NAMESPACE_REGEX_STR}\z/.freeze - end - - def full_namespace_regex - @full_namespace_regex ||= %r{\A#{FULL_NAMESPACE_REGEX_STR}\z} - end - - def namespace_route_regex - @namespace_route_regex ||= begin - illegal_words = Regexp.new(Regexp.union(ILLEGAL_GROUP_PATH_WORDS).source, Regexp::IGNORECASE) - - single_line_regexp %r{ - #{root_namespace_route_regex} - (?: - / - (?!#{illegal_words}/) - #{NAMESPACE_REGEX_STR} - )* - }x - end - end - - def namespace_regex_message - "can contain only letters, digits, '_', '-' and '.'. " \ - "Cannot start with '-' or end in '.', '.git' or '.atom'." \ - end - def namespace_name_regex @namespace_name_regex ||= /\A[\p{Alnum}\p{Pd}_\. ]*\z/.freeze end @@ -216,34 +19,6 @@ module Gitlab "It must start with letter, digit, emoji or '_'." end - def project_path_regex - @project_path_regex ||= %r{\A#{project_route_regex}/\z} - end - - def project_route_regex - @project_route_regex ||= begin - illegal_words = Regexp.new(Regexp.union(ILLEGAL_PROJECT_PATH_WORDS).source, Regexp::IGNORECASE) - - single_line_regexp %r{ - (?!(#{illegal_words})/) - #{PROJECT_REGEX_STR} - }x - end - end - - def project_git_route_regex - @project_git_route_regex ||= /#{project_route_regex}\.git/.freeze - end - - def project_path_format_regex - @project_path_format_regex ||= /\A#{PROJECT_REGEX_STR}\z/.freeze - end - - def project_path_regex_message - "can contain only letters, digits, '_', '-' and '.'. " \ - "Cannot start with '-', end in '.git' or end in '.atom'" \ - end - def file_name_regex @file_name_regex ||= /\A[[[:alnum:]]_\-\.\@\+]*\z/.freeze end @@ -252,36 +27,8 @@ module Gitlab "can contain only letters, digits, '_', '-', '@', '+' and '.'." end - def archive_formats_regex - # |zip|tar| tar.gz | tar.bz2 | - @archive_formats_regex ||= /(zip|tar|tar\.gz|tgz|gz|tar\.bz2|tbz|tbz2|tb2|bz2)/.freeze - end - - def git_reference_regex - # Valid git ref regex, see: - # https://www.kernel.org/pub/software/scm/git/docs/git-check-ref-format.html - - @git_reference_regex ||= single_line_regexp %r{ - (?! - (?# doesn't begins with) - \/| (?# rule #6) - (?# doesn't contain) - .*(?: - [\/.]\.| (?# rule #1,3) - \/\/| (?# rule #6) - @\{| (?# rule #8) - \\ (?# rule #9) - ) - ) - [^\000-\040\177~^:?*\[]+ (?# rule #4-5) - (?# doesn't end with) - (?<!\.lock) (?# rule #1) - (?<![\/.]) (?# rule #6-7) - }x - end - def container_registry_reference_regex - git_reference_regex + Gitlab::PathRegex.git_reference_regex end ## @@ -315,13 +62,5 @@ module Gitlab "can contain only lowercase letters, digits, and '-'. " \ "Must start with a letter, and cannot end with '-'" end - - private - - def single_line_regexp(regex) - # Turns a multiline extended regexp into a single line one, - # beacuse `rake routes` breaks on multiline regexes. - Regexp.new(regex.source.gsub(/\(\?#.+?\)/, '').gsub(/\s*/, ''), regex.options ^ Regexp::EXTENDED).freeze - end end end diff --git a/lib/gitlab/route_map.rb b/lib/gitlab/route_map.rb index 36791fae60f..877aa6e6a28 100644 --- a/lib/gitlab/route_map.rb +++ b/lib/gitlab/route_map.rb @@ -25,8 +25,8 @@ module Gitlab def parse_entry(entry) raise FormatError, 'Route map entry is not a hash' unless entry.is_a?(Hash) - raise FormatError, 'Route map entry does not have a source key' unless entry.has_key?('source') - raise FormatError, 'Route map entry does not have a public key' unless entry.has_key?('public') + raise FormatError, 'Route map entry does not have a source key' unless entry.key?('source') + raise FormatError, 'Route map entry does not have a public key' unless entry.key?('public') source_pattern = entry['source'] public_path = entry['public'] diff --git a/lib/gitlab/routes/legacy_builds.rb b/lib/gitlab/routes/legacy_builds.rb new file mode 100644 index 00000000000..36d1a8a6f64 --- /dev/null +++ b/lib/gitlab/routes/legacy_builds.rb @@ -0,0 +1,36 @@ +module Gitlab + module Routes + class LegacyBuilds + def initialize(map) + @map = map + end + + def draw + @map.instance_eval do + resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do + collection do + resources :artifacts, only: [], controller: 'build_artifacts' do + collection do + get :latest_succeeded, + path: '*ref_name_and_path', + format: false + end + end + end + + member do + get :raw + end + + resource :artifacts, only: [], controller: 'build_artifacts' do + get :download + get :browse, path: 'browse(/*path)', format: false + get :file, path: 'file/*path', format: false + get :raw, path: 'raw/*path', format: false + end + end + end + 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 00000000000..5b1b03820a3 --- /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 9ce13feb79a..c81dc7e30d0 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/gitlab/utils.rb b/lib/gitlab/utils.rb index 4c395b4266e..fa182c4deda 100644 --- a/lib/gitlab/utils.rb +++ b/lib/gitlab/utils.rb @@ -21,5 +21,13 @@ module Gitlab nil end + + def boolean_to_yes_no(bool) + if bool + 'Yes' + else + 'No' + end + end end end diff --git a/lib/gitlab/visibility_level.rb b/lib/gitlab/visibility_level.rb index 2e31f4462f9..85da4c8660b 100644 --- a/lib/gitlab/visibility_level.rb +++ b/lib/gitlab/visibility_level.rb @@ -83,7 +83,7 @@ module Gitlab end def valid_level?(level) - options.has_value?(level) + options.value?(level) end def level_name(level) diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index fe37e4da94f..7f27317775c 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -31,8 +31,7 @@ module Gitlab feature_enabled = case action.to_s when 'git_receive_pack' - # Disabled for now, see https://gitlab.com/gitlab-org/gitaly/issues/172 - false + Gitlab::GitalyClient.feature_enabled?(:post_receive_pack) when 'git_upload_pack' Gitlab::GitalyClient.feature_enabled?(:post_upload_pack) when 'info_refs' @@ -130,7 +129,7 @@ module Gitlab 'MaxSessionTime' => terminal[:max_session_time] } } - details['Terminal']['CAPem'] = terminal[:ca_pem] if terminal.has_key?(:ca_pem) + details['Terminal']['CAPem'] = terminal[:ca_pem] if terminal.key?(:ca_pem) details end diff --git a/lib/support/init.d/gitlab b/lib/support/init.d/gitlab index 6e351365de0..c5f93336346 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 9472c3c992f..295c79fccfc 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/lib/system_check.rb b/lib/system_check.rb new file mode 100644 index 00000000000..466c39904fa --- /dev/null +++ b/lib/system_check.rb @@ -0,0 +1,21 @@ +# Library to perform System Checks +# +# Every Check is implemented as its own class inherited from SystemCheck::BaseCheck +# Execution coordination and boilerplate output is done by the SystemCheck::SimpleExecutor +# +# This structure decouples checks from Rake tasks and facilitates unit-testing +module SystemCheck + # Executes a bunch of checks for specified component + # + # @param [String] component name of the component relative to the checks being executed + # @param [Array<BaseCheck>] checks classes of corresponding checks to be executed in the same order + def self.run(component, checks = []) + executor = SimpleExecutor.new(component) + + checks.each do |check| + executor << check + end + + executor.execute + end +end diff --git a/lib/system_check/app/active_users_check.rb b/lib/system_check/app/active_users_check.rb new file mode 100644 index 00000000000..1d72c8d6903 --- /dev/null +++ b/lib/system_check/app/active_users_check.rb @@ -0,0 +1,17 @@ +module SystemCheck + module App + class ActiveUsersCheck < SystemCheck::BaseCheck + set_name 'Active users:' + + def multi_check + active_users = User.active.count + + if active_users > 0 + $stdout.puts active_users.to_s.color(:green) + else + $stdout.puts active_users.to_s.color(:red) + end + end + end + end +end diff --git a/lib/system_check/app/database_config_exists_check.rb b/lib/system_check/app/database_config_exists_check.rb new file mode 100644 index 00000000000..d1fae192350 --- /dev/null +++ b/lib/system_check/app/database_config_exists_check.rb @@ -0,0 +1,25 @@ +module SystemCheck + module App + class DatabaseConfigExistsCheck < SystemCheck::BaseCheck + set_name 'Database config exists?' + + def check? + database_config_file = Rails.root.join('config', 'database.yml') + + File.exist?(database_config_file) + end + + def show_error + try_fixing_it( + 'Copy config/database.yml.<your db> to config/database.yml', + 'Check that the information in config/database.yml is correct' + ) + for_more_information( + 'doc/install/databases.md', + 'http://guides.rubyonrails.org/getting_started.html#configuring-a-database' + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/git_config_check.rb b/lib/system_check/app/git_config_check.rb new file mode 100644 index 00000000000..198867f7ac6 --- /dev/null +++ b/lib/system_check/app/git_config_check.rb @@ -0,0 +1,42 @@ +module SystemCheck + module App + class GitConfigCheck < SystemCheck::BaseCheck + OPTIONS = { + 'core.autocrlf' => 'input' + }.freeze + + set_name 'Git configured correctly?' + + def check? + correct_options = OPTIONS.map do |name, value| + run_command(%W(#{Gitlab.config.git.bin_path} config --global --get #{name})).try(:squish) == value + end + + correct_options.all? + end + + # Tries to configure git itself + # + # Returns true if all subcommands were successful (according to their exit code) + # Returns false if any or all subcommands failed. + def repair! + return false unless is_gitlab_user? + + command_success = OPTIONS.map do |name, value| + system(*%W(#{Gitlab.config.git.bin_path} config --global #{name} #{value})) + end + + command_success.all? + end + + def show_error + try_fixing_it( + sudo_gitlab("\"#{Gitlab.config.git.bin_path}\" config --global core.autocrlf \"#{OPTIONS['core.autocrlf']}\"") + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + end + end + end +end diff --git a/lib/system_check/app/git_version_check.rb b/lib/system_check/app/git_version_check.rb new file mode 100644 index 00000000000..c388682dfb4 --- /dev/null +++ b/lib/system_check/app/git_version_check.rb @@ -0,0 +1,29 @@ +module SystemCheck + module App + class GitVersionCheck < SystemCheck::BaseCheck + set_name -> { "Git version >= #{self.required_version} ?" } + set_check_pass -> { "yes (#{self.current_version})" } + + def self.required_version + @required_version ||= Gitlab::VersionInfo.new(2, 7, 3) + end + + def self.current_version + @current_version ||= Gitlab::VersionInfo.parse(run_command(%W(#{Gitlab.config.git.bin_path} --version))) + end + + def check? + self.class.current_version.valid? && self.class.required_version <= self.class.current_version + end + + def show_error + $stdout.puts "Your git bin path is \"#{Gitlab.config.git.bin_path}\"" + + try_fixing_it( + "Update your git to a version >= #{self.class.required_version} from #{self.class.current_version}" + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/gitlab_config_exists_check.rb b/lib/system_check/app/gitlab_config_exists_check.rb new file mode 100644 index 00000000000..247aa0994e4 --- /dev/null +++ b/lib/system_check/app/gitlab_config_exists_check.rb @@ -0,0 +1,24 @@ +module SystemCheck + module App + class GitlabConfigExistsCheck < SystemCheck::BaseCheck + set_name 'GitLab config exists?' + + def check? + gitlab_config_file = Rails.root.join('config', 'gitlab.yml') + + File.exist?(gitlab_config_file) + end + + def show_error + try_fixing_it( + 'Copy config/gitlab.yml.example to config/gitlab.yml', + 'Update config/gitlab.yml to match your setup' + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/gitlab_config_up_to_date_check.rb b/lib/system_check/app/gitlab_config_up_to_date_check.rb new file mode 100644 index 00000000000..c609e48e133 --- /dev/null +++ b/lib/system_check/app/gitlab_config_up_to_date_check.rb @@ -0,0 +1,30 @@ +module SystemCheck + module App + class GitlabConfigUpToDateCheck < SystemCheck::BaseCheck + set_name 'GitLab config up to date?' + set_skip_reason "can't check because of previous errors" + + def skip? + gitlab_config_file = Rails.root.join('config', 'gitlab.yml') + !File.exist?(gitlab_config_file) + end + + def check? + # omniauth or ldap could have been deleted from the file + !Gitlab.config['git_host'] + end + + def show_error + try_fixing_it( + 'Back-up your config/gitlab.yml', + 'Copy config/gitlab.yml.example to config/gitlab.yml', + 'Update config/gitlab.yml to match your setup' + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/init_script_exists_check.rb b/lib/system_check/app/init_script_exists_check.rb new file mode 100644 index 00000000000..d246e058e86 --- /dev/null +++ b/lib/system_check/app/init_script_exists_check.rb @@ -0,0 +1,27 @@ +module SystemCheck + module App + class InitScriptExistsCheck < SystemCheck::BaseCheck + set_name 'Init script exists?' + set_skip_reason 'skipped (omnibus-gitlab has no init script)' + + def skip? + omnibus_gitlab? + end + + def check? + script_path = '/etc/init.d/gitlab' + File.exist?(script_path) + end + + def show_error + try_fixing_it( + 'Install the init script' + ) + for_more_information( + see_installation_guide_section 'Install Init Script' + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/init_script_up_to_date_check.rb b/lib/system_check/app/init_script_up_to_date_check.rb new file mode 100644 index 00000000000..015c7ed1731 --- /dev/null +++ b/lib/system_check/app/init_script_up_to_date_check.rb @@ -0,0 +1,43 @@ +module SystemCheck + module App + class InitScriptUpToDateCheck < SystemCheck::BaseCheck + SCRIPT_PATH = '/etc/init.d/gitlab'.freeze + + set_name 'Init script up-to-date?' + set_skip_reason 'skipped (omnibus-gitlab has no init script)' + + def skip? + omnibus_gitlab? + end + + def multi_check + recipe_path = Rails.root.join('lib/support/init.d/', 'gitlab') + + unless File.exist?(SCRIPT_PATH) + $stdout.puts "can't check because of previous errors".color(:magenta) + return + end + + recipe_content = File.read(recipe_path) + script_content = File.read(SCRIPT_PATH) + + if recipe_content == script_content + $stdout.puts 'yes'.color(:green) + else + $stdout.puts 'no'.color(:red) + show_error + end + end + + def show_error + try_fixing_it( + 'Re-download the init script' + ) + for_more_information( + see_installation_guide_section 'Install Init Script' + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/log_writable_check.rb b/lib/system_check/app/log_writable_check.rb new file mode 100644 index 00000000000..3e0c436d6ee --- /dev/null +++ b/lib/system_check/app/log_writable_check.rb @@ -0,0 +1,28 @@ +module SystemCheck + module App + class LogWritableCheck < SystemCheck::BaseCheck + set_name 'Log directory writable?' + + def check? + File.writable?(log_path) + end + + def show_error + try_fixing_it( + "sudo chown -R gitlab #{log_path}", + "sudo chmod -R u+rwX #{log_path}" + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + fix_and_rerun + end + + private + + def log_path + Rails.root.join('log') + end + end + end +end diff --git a/lib/system_check/app/migrations_are_up_check.rb b/lib/system_check/app/migrations_are_up_check.rb new file mode 100644 index 00000000000..5eedbacce77 --- /dev/null +++ b/lib/system_check/app/migrations_are_up_check.rb @@ -0,0 +1,20 @@ +module SystemCheck + module App + class MigrationsAreUpCheck < SystemCheck::BaseCheck + set_name 'All migrations up?' + + def check? + migration_status, _ = Gitlab::Popen.popen(%w(bundle exec rake db:migrate:status)) + + migration_status !~ /down\s+\d{14}/ + end + + def show_error + try_fixing_it( + sudo_gitlab('bundle exec rake db:migrate RAILS_ENV=production') + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/orphaned_group_members_check.rb b/lib/system_check/app/orphaned_group_members_check.rb new file mode 100644 index 00000000000..2b46d36fe51 --- /dev/null +++ b/lib/system_check/app/orphaned_group_members_check.rb @@ -0,0 +1,20 @@ +module SystemCheck + module App + class OrphanedGroupMembersCheck < SystemCheck::BaseCheck + set_name 'Database contains orphaned GroupMembers?' + set_check_pass 'no' + set_check_fail 'yes' + + def check? + !GroupMember.where('user_id not in (select id from users)').exists? + end + + def show_error + try_fixing_it( + 'You can delete the orphaned records using something along the lines of:', + sudo_gitlab("bundle exec rails runner -e production 'GroupMember.where(\"user_id NOT IN (SELECT id FROM users)\").delete_all'") + ) + end + end + end +end diff --git a/lib/system_check/app/projects_have_namespace_check.rb b/lib/system_check/app/projects_have_namespace_check.rb new file mode 100644 index 00000000000..a6ec9f7665c --- /dev/null +++ b/lib/system_check/app/projects_have_namespace_check.rb @@ -0,0 +1,37 @@ +module SystemCheck + module App + class ProjectsHaveNamespaceCheck < SystemCheck::BaseCheck + set_name 'Projects have namespace:' + set_skip_reason "can't check, you have no projects" + + def skip? + !Project.exists? + end + + def multi_check + $stdout.puts '' + + Project.find_each(batch_size: 100) do |project| + $stdout.print sanitized_message(project) + + if project.namespace + $stdout.puts 'yes'.color(:green) + else + $stdout.puts 'no'.color(:red) + show_error + end + end + end + + def show_error + try_fixing_it( + "Migrate global projects" + ) + for_more_information( + "doc/update/5.4-to-6.0.md in section \"#global-projects\"" + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/redis_version_check.rb b/lib/system_check/app/redis_version_check.rb new file mode 100644 index 00000000000..a0610e73576 --- /dev/null +++ b/lib/system_check/app/redis_version_check.rb @@ -0,0 +1,25 @@ +module SystemCheck + module App + class RedisVersionCheck < SystemCheck::BaseCheck + MIN_REDIS_VERSION = '2.8.0'.freeze + set_name "Redis version >= #{MIN_REDIS_VERSION}?" + + def check? + redis_version = run_command(%w(redis-cli --version)) + redis_version = redis_version.try(:match, /redis-cli (\d+\.\d+\.\d+)/) + + redis_version && (Gem::Version.new(redis_version[1]) > Gem::Version.new(MIN_REDIS_VERSION)) + end + + def show_error + try_fixing_it( + "Update your redis server to a version >= #{MIN_REDIS_VERSION}" + ) + for_more_information( + 'gitlab-public-wiki/wiki/Trouble-Shooting-Guide in section sidekiq' + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/ruby_version_check.rb b/lib/system_check/app/ruby_version_check.rb new file mode 100644 index 00000000000..fd82f5f8a4a --- /dev/null +++ b/lib/system_check/app/ruby_version_check.rb @@ -0,0 +1,27 @@ +module SystemCheck + module App + class RubyVersionCheck < SystemCheck::BaseCheck + set_name -> { "Ruby version >= #{self.required_version} ?" } + set_check_pass -> { "yes (#{self.current_version})" } + + def self.required_version + @required_version ||= Gitlab::VersionInfo.new(2, 3, 3) + end + + def self.current_version + @current_version ||= Gitlab::VersionInfo.parse(run_command(%w(ruby --version))) + end + + def check? + self.class.current_version.valid? && self.class.required_version <= self.class.current_version + end + + def show_error + try_fixing_it( + "Update your ruby to a version >= #{self.class.required_version} from #{self.class.current_version}" + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/tmp_writable_check.rb b/lib/system_check/app/tmp_writable_check.rb new file mode 100644 index 00000000000..99a75e57abf --- /dev/null +++ b/lib/system_check/app/tmp_writable_check.rb @@ -0,0 +1,28 @@ +module SystemCheck + module App + class TmpWritableCheck < SystemCheck::BaseCheck + set_name 'Tmp directory writable?' + + def check? + File.writable?(tmp_path) + end + + def show_error + try_fixing_it( + "sudo chown -R gitlab #{tmp_path}", + "sudo chmod -R u+rwX #{tmp_path}" + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + fix_and_rerun + end + + private + + def tmp_path + Rails.root.join('tmp') + end + end + end +end diff --git a/lib/system_check/app/uploads_directory_exists_check.rb b/lib/system_check/app/uploads_directory_exists_check.rb new file mode 100644 index 00000000000..7026d0ba075 --- /dev/null +++ b/lib/system_check/app/uploads_directory_exists_check.rb @@ -0,0 +1,21 @@ +module SystemCheck + module App + class UploadsDirectoryExistsCheck < SystemCheck::BaseCheck + set_name 'Uploads directory exists?' + + def check? + File.directory?(Rails.root.join('public/uploads')) + end + + def show_error + try_fixing_it( + "sudo -u #{gitlab_user} mkdir #{Rails.root}/public/uploads" + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + fix_and_rerun + end + end + end +end diff --git a/lib/system_check/app/uploads_path_permission_check.rb b/lib/system_check/app/uploads_path_permission_check.rb new file mode 100644 index 00000000000..7df6c060254 --- /dev/null +++ b/lib/system_check/app/uploads_path_permission_check.rb @@ -0,0 +1,36 @@ +module SystemCheck + module App + class UploadsPathPermissionCheck < SystemCheck::BaseCheck + set_name 'Uploads directory has correct permissions?' + set_skip_reason 'skipped (no uploads folder found)' + + def skip? + !File.directory?(rails_uploads_path) + end + + def check? + File.stat(uploads_fullpath).mode == 040700 + end + + def show_error + try_fixing_it( + "sudo chmod 700 #{uploads_fullpath}" + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + fix_and_rerun + end + + private + + def rails_uploads_path + Rails.root.join('public/uploads') + end + + def uploads_fullpath + File.realpath(rails_uploads_path) + end + end + end +end diff --git a/lib/system_check/app/uploads_path_tmp_permission_check.rb b/lib/system_check/app/uploads_path_tmp_permission_check.rb new file mode 100644 index 00000000000..b276a81eac1 --- /dev/null +++ b/lib/system_check/app/uploads_path_tmp_permission_check.rb @@ -0,0 +1,40 @@ +module SystemCheck + module App + class UploadsPathTmpPermissionCheck < SystemCheck::BaseCheck + set_name 'Uploads directory tmp has correct permissions?' + set_skip_reason 'skipped (no tmp uploads folder yet)' + + def skip? + !File.directory?(uploads_fullpath) || !Dir.exist?(upload_path_tmp) + end + + def check? + # If tmp upload dir has incorrect permissions, assume others do as well + # Verify drwx------ permissions + File.stat(upload_path_tmp).mode == 040700 && File.owned?(upload_path_tmp) + end + + def show_error + try_fixing_it( + "sudo chown -R #{gitlab_user} #{uploads_fullpath}", + "sudo find #{uploads_fullpath} -type f -exec chmod 0644 {} \\;", + "sudo find #{uploads_fullpath} -type d -not -path #{uploads_fullpath} -exec chmod 0700 {} \\;" + ) + for_more_information( + see_installation_guide_section 'GitLab' + ) + fix_and_rerun + end + + private + + def upload_path_tmp + File.join(uploads_fullpath, 'tmp') + end + + def uploads_fullpath + File.realpath(Rails.root.join('public/uploads')) + end + end + end +end diff --git a/lib/system_check/base_check.rb b/lib/system_check/base_check.rb new file mode 100644 index 00000000000..5dcb3f0886b --- /dev/null +++ b/lib/system_check/base_check.rb @@ -0,0 +1,129 @@ +module SystemCheck + # Base class for Checks. You must inherit from here + # and implement the methods below when necessary + class BaseCheck + include ::SystemCheck::Helpers + + # Define a custom term for when check passed + # + # @param [String] term used when check passed (default: 'yes') + def self.set_check_pass(term) + @check_pass = term + end + + # Define a custom term for when check failed + # + # @param [String] term used when check failed (default: 'no') + def self.set_check_fail(term) + @check_fail = term + end + + # Define the name of the SystemCheck that will be displayed during execution + # + # @param [String] name of the check + def self.set_name(name) + @name = name + end + + # Define the reason why we skipped the SystemCheck + # + # This is only used if subclass implements `#skip?` + # + # @param [String] reason to be displayed + def self.set_skip_reason(reason) + @skip_reason = reason + end + + # Term to be displayed when check passed + # + # @return [String] term when check passed ('yes' if not re-defined in a subclass) + def self.check_pass + call_or_return(@check_pass) || 'yes' + end + + ## Term to be displayed when check failed + # + # @return [String] term when check failed ('no' if not re-defined in a subclass) + def self.check_fail + call_or_return(@check_fail) || 'no' + end + + # Name of the SystemCheck defined by the subclass + # + # @return [String] the name + def self.display_name + call_or_return(@name) || self.name + end + + # Skip reason defined by the subclass + # + # @return [String] the reason + def self.skip_reason + call_or_return(@skip_reason) || 'skipped' + end + + # Does the check support automatically repair routine? + # + # @return [Boolean] whether check implemented `#repair!` method or not + def can_repair? + self.class.instance_methods(false).include?(:repair!) + end + + def can_skip? + self.class.instance_methods(false).include?(:skip?) + end + + def is_multi_check? + self.class.instance_methods(false).include?(:multi_check) + end + + # Execute the check routine + # + # This is where you should implement the main logic that will return + # a boolean at the end + # + # You should not print any output to STDOUT here, use the specific methods instead + # + # @return [Boolean] whether check passed or failed + def check? + raise NotImplementedError + end + + # Execute a custom check that cover multiple unities + # + # When using multi_check you have to provide the output yourself + def multi_check + raise NotImplementedError + end + + # Prints troubleshooting instructions + # + # This is where you should print detailed information for any error found during #check? + # + # You may use helper methods to help format the output: + # + # @see #try_fixing_it + # @see #fix_and_rerun + # @see #for_more_infromation + def show_error + raise NotImplementedError + end + + # When implemented by a subclass, will attempt to fix the issue automatically + def repair! + raise NotImplementedError + end + + # When implemented by a subclass, will evaluate whether check should be skipped or not + # + # @return [Boolean] whether or not this check should be skipped + def skip? + raise NotImplementedError + end + + def self.call_or_return(input) + input.respond_to?(:call) ? input.call : input + end + private_class_method :call_or_return + end +end diff --git a/lib/system_check/helpers.rb b/lib/system_check/helpers.rb new file mode 100644 index 00000000000..c42ae4fe4c4 --- /dev/null +++ b/lib/system_check/helpers.rb @@ -0,0 +1,75 @@ +require 'tasks/gitlab/task_helpers' + +module SystemCheck + module Helpers + include ::Gitlab::TaskHelpers + + # Display a message telling to fix and rerun the checks + def fix_and_rerun + $stdout.puts ' Please fix the error above and rerun the checks.'.color(:red) + end + + # Display a formatted list of references (documentation or links) where to find more information + # + # @param [Array<String>] sources one or more references (documentation or links) + def for_more_information(*sources) + $stdout.puts ' For more information see:'.color(:blue) + sources.each do |source| + $stdout.puts " #{source}" + end + end + + def see_installation_guide_section(section) + "doc/install/installation.md in section \"#{section}\"" + end + + # @deprecated This will no longer be used when all checks were executed using SystemCheck + def finished_checking(component) + $stdout.puts '' + $stdout.puts "Checking #{component.color(:yellow)} ... #{'Finished'.color(:green)}" + $stdout.puts '' + end + + # @deprecated This will no longer be used when all checks were executed using SystemCheck + def start_checking(component) + $stdout.puts "Checking #{component.color(:yellow)} ..." + $stdout.puts '' + end + + # Display a formatted list of instructions on how to fix the issue identified by the #check? + # + # @param [Array<String>] steps one or short sentences with help how to fix the issue + def try_fixing_it(*steps) + steps = steps.shift if steps.first.is_a?(Array) + + $stdout.puts ' Try fixing it:'.color(:blue) + steps.each do |step| + $stdout.puts " #{step}" + end + end + + def sanitized_message(project) + if should_sanitize? + "#{project.namespace_id.to_s.color(:yellow)}/#{project.id.to_s.color(:yellow)} ... " + else + "#{project.name_with_namespace.color(:yellow)} ... " + end + end + + def should_sanitize? + if ENV['SANITIZE'] == 'true' + true + else + false + end + end + + def omnibus_gitlab? + Dir.pwd == '/opt/gitlab/embedded/service/gitlab-rails' + end + + def sudo_gitlab(command) + "sudo -u #{gitlab_user} -H #{command}" + end + end +end diff --git a/lib/system_check/simple_executor.rb b/lib/system_check/simple_executor.rb new file mode 100644 index 00000000000..dc2d4643a01 --- /dev/null +++ b/lib/system_check/simple_executor.rb @@ -0,0 +1,99 @@ +module SystemCheck + # Simple Executor is current default executor for GitLab + # It is a simple port from display logic in the old check.rake + # + # There is no concurrency level and the output is progressively + # printed into the STDOUT + # + # @attr_reader [Array<BaseCheck>] checks classes of corresponding checks to be executed in the same order + # @attr_reader [String] component name of the component relative to the checks being executed + class SimpleExecutor + attr_reader :checks + attr_reader :component + + # @param [String] component name of the component relative to the checks being executed + def initialize(component) + raise ArgumentError unless component.is_a? String + + @component = component + @checks = Set.new + end + + # Add a check to be executed + # + # @param [BaseCheck] check class + def <<(check) + raise ArgumentError unless check < BaseCheck + @checks << check + end + + # Executes defined checks in the specified order and outputs confirmation or error information + def execute + start_checking(component) + + @checks.each do |check| + run_check(check) + end + + finished_checking(component) + end + + # Executes a single check + # + # @param [SystemCheck::BaseCheck] check_klass + def run_check(check_klass) + $stdout.print "#{check_klass.display_name} ... " + + check = check_klass.new + + # When implements skip method, we run it first, and if true, skip the check + if check.can_skip? && check.skip? + $stdout.puts check_klass.skip_reason.color(:magenta) + return + end + + # When implements a multi check, we don't control the output + if check.is_multi_check? + check.multi_check + return + end + + if check.check? + $stdout.puts check_klass.check_pass.color(:green) + else + $stdout.puts check_klass.check_fail.color(:red) + + if check.can_repair? + $stdout.print 'Trying to fix error automatically. ...' + if check.repair! + $stdout.puts 'Success'.color(:green) + return + else + $stdout.puts 'Failed'.color(:red) + end + end + + check.show_error + end + end + + private + + # Prints header content for the series of checks to be executed for this component + # + # @param [String] component name of the component relative to the checks being executed + def start_checking(component) + $stdout.puts "Checking #{component.color(:yellow)} ..." + $stdout.puts '' + end + + # Prints footer content for the series of checks executed for this component + # + # @param [String] component name of the component relative to the checks being executed + def finished_checking(component) + $stdout.puts '' + $stdout.puts "Checking #{component.color(:yellow)} ... #{'Finished'.color(:green)}" + $stdout.puts '' + end + end +end diff --git a/lib/tasks/gettext.rake b/lib/tasks/gettext.rake index 0aa21a4bd13..b27f7475115 100644 --- a/lib/tasks/gettext.rake +++ b/lib/tasks/gettext.rake @@ -11,4 +11,12 @@ namespace :gettext do "{#{folders}}/**/*.{#{exts}}" ) end + + task :compile do + # See: https://gitlab.com/gitlab-org/gitlab-ce/issues/33014#note_31218998 + FileUtils.touch(File.join(Rails.root, 'locale/gitlab.pot')) + + Rake::Task['gettext:pack'].invoke + Rake::Task['gettext:po_to_json'].invoke + end end diff --git a/lib/tasks/gitlab/check.rake b/lib/tasks/gitlab/check.rake index f41c73154f5..63c5e9b9c83 100644 --- a/lib/tasks/gitlab/check.rake +++ b/lib/tasks/gitlab/check.rake @@ -1,5 +1,9 @@ +# Temporary hack, until we migrate all checks to SystemCheck format +require 'system_check' +require 'system_check/helpers' + namespace :gitlab do - desc "GitLab | Check the configuration of GitLab and its environment" + desc 'GitLab | Check the configuration of GitLab and its environment' task check: %w{gitlab:gitlab_shell:check gitlab:sidekiq:check gitlab:incoming_email:check @@ -7,331 +11,38 @@ namespace :gitlab do gitlab:app:check} namespace :app do - desc "GitLab | Check the configuration of the GitLab Rails app" + desc 'GitLab | Check the configuration of the GitLab Rails app' task check: :environment do warn_user_is_not_gitlab - start_checking "GitLab" - - check_git_config - check_database_config_exists - check_migrations_are_up - check_orphaned_group_members - check_gitlab_config_exists - check_gitlab_config_not_outdated - check_log_writable - check_tmp_writable - check_uploads - check_init_script_exists - check_init_script_up_to_date - check_projects_have_namespace - check_redis_version - check_ruby_version - check_git_version - check_active_users - - finished_checking "GitLab" - end - - # Checks - ######################## - - def check_git_config - print "Git configured with autocrlf=input? ... " - - options = { - "core.autocrlf" => "input" - } - - correct_options = options.map do |name, value| - run_command(%W(#{Gitlab.config.git.bin_path} config --global --get #{name})).try(:squish) == value - end - - if correct_options.all? - puts "yes".color(:green) - else - print "Trying to fix Git error automatically. ..." - - if auto_fix_git_config(options) - puts "Success".color(:green) - else - puts "Failed".color(:red) - try_fixing_it( - sudo_gitlab("\"#{Gitlab.config.git.bin_path}\" config --global core.autocrlf \"#{options["core.autocrlf"]}\"") - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - end - end - end - - def check_database_config_exists - print "Database config exists? ... " - - database_config_file = Rails.root.join("config", "database.yml") - - if File.exist?(database_config_file) - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Copy config/database.yml.<your db> to config/database.yml", - "Check that the information in config/database.yml is correct" - ) - for_more_information( - see_database_guide, - "http://guides.rubyonrails.org/getting_started.html#configuring-a-database" - ) - fix_and_rerun - end - end - - def check_gitlab_config_exists - print "GitLab config exists? ... " - - gitlab_config_file = Rails.root.join("config", "gitlab.yml") - - if File.exist?(gitlab_config_file) - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Copy config/gitlab.yml.example to config/gitlab.yml", - "Update config/gitlab.yml to match your setup" - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - fix_and_rerun - end - end - - def check_gitlab_config_not_outdated - print "GitLab config outdated? ... " - - gitlab_config_file = Rails.root.join("config", "gitlab.yml") - unless File.exist?(gitlab_config_file) - puts "can't check because of previous errors".color(:magenta) - end - - # omniauth or ldap could have been deleted from the file - unless Gitlab.config['git_host'] - puts "no".color(:green) - else - puts "yes".color(:red) - try_fixing_it( - "Backup your config/gitlab.yml", - "Copy config/gitlab.yml.example to config/gitlab.yml", - "Update config/gitlab.yml to match your setup" - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - fix_and_rerun - end - end - - def check_init_script_exists - print "Init script exists? ... " - - if omnibus_gitlab? - puts 'skipped (omnibus-gitlab has no init script)'.color(:magenta) - return - end - - script_path = "/etc/init.d/gitlab" - - if File.exist?(script_path) - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Install the init script" - ) - for_more_information( - see_installation_guide_section "Install Init Script" - ) - fix_and_rerun - end - end - - def check_init_script_up_to_date - print "Init script up-to-date? ... " - - if omnibus_gitlab? - puts 'skipped (omnibus-gitlab has no init script)'.color(:magenta) - return - end - - recipe_path = Rails.root.join("lib/support/init.d/", "gitlab") - script_path = "/etc/init.d/gitlab" - - unless File.exist?(script_path) - puts "can't check because of previous errors".color(:magenta) - return - end - - recipe_content = File.read(recipe_path) - script_content = File.read(script_path) - - if recipe_content == script_content - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Redownload the init script" - ) - for_more_information( - see_installation_guide_section "Install Init Script" - ) - fix_and_rerun - end - end - - def check_migrations_are_up - print "All migrations up? ... " - - migration_status, _ = Gitlab::Popen.popen(%w(bundle exec rake db:migrate:status)) - - unless migration_status =~ /down\s+\d{14}/ - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - sudo_gitlab("bundle exec rake db:migrate RAILS_ENV=production") - ) - fix_and_rerun - end - end - - def check_orphaned_group_members - print "Database contains orphaned GroupMembers? ... " - if GroupMember.where("user_id not in (select id from users)").count > 0 - puts "yes".color(:red) - try_fixing_it( - "You can delete the orphaned records using something along the lines of:", - sudo_gitlab("bundle exec rails runner -e production 'GroupMember.where(\"user_id NOT IN (SELECT id FROM users)\").delete_all'") - ) - else - puts "no".color(:green) - end - end - - def check_log_writable - print "Log directory writable? ... " - - log_path = Rails.root.join("log") - - if File.writable?(log_path) - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "sudo chown -R gitlab #{log_path}", - "sudo chmod -R u+rwX #{log_path}" - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - fix_and_rerun - end - end - def check_tmp_writable - print "Tmp directory writable? ... " - - tmp_path = Rails.root.join("tmp") - - if File.writable?(tmp_path) - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "sudo chown -R gitlab #{tmp_path}", - "sudo chmod -R u+rwX #{tmp_path}" - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - fix_and_rerun - end - end - - def check_uploads - print "Uploads directory setup correctly? ... " - - unless File.directory?(Rails.root.join('public/uploads')) - puts "no".color(:red) - try_fixing_it( - "sudo -u #{gitlab_user} mkdir #{Rails.root}/public/uploads" - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - fix_and_rerun - return - end - - upload_path = File.realpath(Rails.root.join('public/uploads')) - upload_path_tmp = File.join(upload_path, 'tmp') - - if File.stat(upload_path).mode == 040700 - unless Dir.exist?(upload_path_tmp) - puts 'skipped (no tmp uploads folder yet)'.color(:magenta) - return - end - - # If tmp upload dir has incorrect permissions, assume others do as well - # Verify drwx------ permissions - if File.stat(upload_path_tmp).mode == 040700 && File.owned?(upload_path_tmp) - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "sudo chown -R #{gitlab_user} #{upload_path}", - "sudo find #{upload_path} -type f -exec chmod 0644 {} \\;", - "sudo find #{upload_path} -type d -not -path #{upload_path} -exec chmod 0700 {} \\;" - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - fix_and_rerun - end - else - puts "no".color(:red) - try_fixing_it( - "sudo chmod 700 #{upload_path}" - ) - for_more_information( - see_installation_guide_section "GitLab" - ) - fix_and_rerun - end - end - - def check_redis_version - min_redis_version = "2.8.0" - print "Redis version >= #{min_redis_version}? ... " - - redis_version = run_command(%w(redis-cli --version)) - redis_version = redis_version.try(:match, /redis-cli (\d+\.\d+\.\d+)/) - if redis_version && - (Gem::Version.new(redis_version[1]) > Gem::Version.new(min_redis_version)) - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Update your redis server to a version >= #{min_redis_version}" - ) - for_more_information( - "gitlab-public-wiki/wiki/Trouble-Shooting-Guide in section sidekiq" - ) - fix_and_rerun - end + checks = [ + SystemCheck::App::GitConfigCheck, + SystemCheck::App::DatabaseConfigExistsCheck, + SystemCheck::App::MigrationsAreUpCheck, + SystemCheck::App::OrphanedGroupMembersCheck, + SystemCheck::App::GitlabConfigExistsCheck, + SystemCheck::App::GitlabConfigUpToDateCheck, + SystemCheck::App::LogWritableCheck, + SystemCheck::App::TmpWritableCheck, + SystemCheck::App::UploadsDirectoryExistsCheck, + SystemCheck::App::UploadsPathPermissionCheck, + SystemCheck::App::UploadsPathTmpPermissionCheck, + SystemCheck::App::InitScriptExistsCheck, + SystemCheck::App::InitScriptUpToDateCheck, + SystemCheck::App::ProjectsHaveNamespaceCheck, + SystemCheck::App::RedisVersionCheck, + SystemCheck::App::RubyVersionCheck, + SystemCheck::App::GitVersionCheck, + SystemCheck::App::ActiveUsersCheck + ] + + SystemCheck.run('GitLab', checks) end end namespace :gitlab_shell do + include SystemCheck::Helpers + desc "GitLab | Check the configuration of GitLab Shell" task check: :environment do warn_user_is_not_gitlab @@ -513,33 +224,6 @@ namespace :gitlab do end end - def check_projects_have_namespace - print "projects have namespace: ... " - - unless Project.count > 0 - puts "can't check, you have no projects".color(:magenta) - return - end - puts "" - - Project.find_each(batch_size: 100) do |project| - print sanitized_message(project) - - if project.namespace - puts "yes".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Migrate global projects" - ) - for_more_information( - "doc/update/5.4-to-6.0.md in section \"#global-projects\"" - ) - fix_and_rerun - end - end - end - # Helper methods ######################## @@ -565,6 +249,8 @@ namespace :gitlab do end namespace :sidekiq do + include SystemCheck::Helpers + desc "GitLab | Check the configuration of Sidekiq" task check: :environment do warn_user_is_not_gitlab @@ -623,6 +309,8 @@ namespace :gitlab do end namespace :incoming_email do + include SystemCheck::Helpers + desc "GitLab | Check the configuration of Reply by email" task check: :environment do warn_user_is_not_gitlab @@ -757,6 +445,8 @@ namespace :gitlab do end namespace :ldap do + include SystemCheck::Helpers + task :check, [:limit] => :environment do |_, args| # Only show up to 100 results because LDAP directories can be very big. # This setting only affects the `rake gitlab:check` script. @@ -812,6 +502,8 @@ namespace :gitlab do end namespace :repo do + include SystemCheck::Helpers + desc "GitLab | Check the integrity of the repositories managed by GitLab" task check: :environment do Gitlab.config.repositories.storages.each do |name, repository_storage| @@ -826,6 +518,8 @@ namespace :gitlab do end namespace :user do + include SystemCheck::Helpers + desc "GitLab | Check the integrity of a specific user's repositories" task :check_repos, [:username] => :environment do |t, args| username = args[:username] || prompt("Check repository integrity for fsername? ".color(:blue)) @@ -848,55 +542,6 @@ namespace :gitlab do # Helper methods ########################## - def fix_and_rerun - puts " Please fix the error above and rerun the checks.".color(:red) - end - - def for_more_information(*sources) - sources = sources.shift if sources.first.is_a?(Array) - - puts " For more information see:".color(:blue) - sources.each do |source| - puts " #{source}" - end - end - - def finished_checking(component) - puts "" - puts "Checking #{component.color(:yellow)} ... #{"Finished".color(:green)}" - puts "" - end - - def see_database_guide - "doc/install/databases.md" - end - - def see_installation_guide_section(section) - "doc/install/installation.md in section \"#{section}\"" - end - - def sudo_gitlab(command) - "sudo -u #{gitlab_user} -H #{command}" - end - - def gitlab_user - Gitlab.config.gitlab.user - end - - def start_checking(component) - puts "Checking #{component.color(:yellow)} ..." - puts "" - end - - def try_fixing_it(*steps) - steps = steps.shift if steps.first.is_a?(Array) - - puts " Try fixing it:".color(:blue) - steps.each do |step| - puts " #{step}" - end - end - def check_gitlab_shell required_version = Gitlab::VersionInfo.new(gitlab_shell_major_version, gitlab_shell_minor_version, gitlab_shell_patch_version) current_version = Gitlab::VersionInfo.parse(gitlab_shell_version) @@ -909,65 +554,6 @@ namespace :gitlab do end end - def check_ruby_version - required_version = Gitlab::VersionInfo.new(2, 1, 0) - current_version = Gitlab::VersionInfo.parse(run_command(%w(ruby --version))) - - print "Ruby version >= #{required_version} ? ... " - - if current_version.valid? && required_version <= current_version - puts "yes (#{current_version})".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Update your ruby to a version >= #{required_version} from #{current_version}" - ) - fix_and_rerun - end - end - - def check_git_version - required_version = Gitlab::VersionInfo.new(2, 7, 3) - current_version = Gitlab::VersionInfo.parse(run_command(%W(#{Gitlab.config.git.bin_path} --version))) - - puts "Your git bin path is \"#{Gitlab.config.git.bin_path}\"" - print "Git version >= #{required_version} ? ... " - - if current_version.valid? && required_version <= current_version - puts "yes (#{current_version})".color(:green) - else - puts "no".color(:red) - try_fixing_it( - "Update your git to a version >= #{required_version} from #{current_version}" - ) - fix_and_rerun - end - end - - def check_active_users - puts "Active users: #{User.active.count}" - end - - def omnibus_gitlab? - Dir.pwd == '/opt/gitlab/embedded/service/gitlab-rails' - end - - def sanitized_message(project) - if should_sanitize? - "#{project.namespace_id.to_s.color(:yellow)}/#{project.id.to_s.color(:yellow)} ... " - else - "#{project.name_with_namespace.color(:yellow)} ... " - end - end - - def should_sanitize? - if ENV['SANITIZE'] == "true" - true - else - false - end - end - def check_repo_integrity(repo_dir) puts "\nChecking repo at #{repo_dir.color(:yellow)}" diff --git a/lib/tasks/gitlab/task_helpers.rb b/lib/tasks/gitlab/task_helpers.rb index e3c9d3b491c..964aa0fe1bc 100644 --- a/lib/tasks/gitlab/task_helpers.rb +++ b/lib/tasks/gitlab/task_helpers.rb @@ -98,34 +98,30 @@ module Gitlab end end + def gitlab_user + Gitlab.config.gitlab.user + end + + def is_gitlab_user? + return @is_gitlab_user unless @is_gitlab_user.nil? + + current_user = run_command(%w(whoami)).chomp + @is_gitlab_user = current_user == gitlab_user + end + def warn_user_is_not_gitlab - unless @warned_user_not_gitlab - gitlab_user = Gitlab.config.gitlab.user + return if @warned_user_not_gitlab + + unless is_gitlab_user? current_user = run_command(%w(whoami)).chomp - unless current_user == gitlab_user - puts " Warning ".color(:black).background(:yellow) - puts " You are running as user #{current_user.color(:magenta)}, we hope you know what you are doing." - puts " Things may work\/fail for the wrong reasons." - puts " For correct results you should run this as user #{gitlab_user.color(:magenta)}." - puts "" - end - @warned_user_not_gitlab = true - end - end - # Tries to configure git itself - # - # Returns true if all subcommands were successfull (according to their exit code) - # Returns false if any or all subcommands failed. - def auto_fix_git_config(options) - if !@warned_user_not_gitlab - command_success = options.map do |name, value| - system(*%W(#{Gitlab.config.git.bin_path} config --global #{name} #{value})) - end + puts " Warning ".color(:black).background(:yellow) + puts " You are running as user #{current_user.color(:magenta)}, we hope you know what you are doing." + puts " Things may work\/fail for the wrong reasons." + puts " For correct results you should run this as user #{gitlab_user.color(:magenta)}." + puts "" - command_success.all? - else - false + @warned_user_not_gitlab = true end end diff --git a/package.json b/package.json index 800327d8a08..29165fd4182 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "core-js": "^2.4.1", "css-loader": "^0.28.0", "d3": "^3.5.11", + "deckar01-task_list": "^2.0.0", "document-register-element": "^1.3.0", "dropzone": "^4.2.0", "emoji-unicode-version": "^0.2.1", @@ -36,6 +37,7 @@ "jszip-utils": "^0.0.2", "marked": "^0.3.6", "mousetrap": "^1.4.6", + "name-all-modules-plugin": "^1.0.1", "pdfjs-dist": "^1.8.252", "pikaday": "^1.5.1", "prismjs": "^1.6.0", @@ -57,8 +59,8 @@ "vue-loader": "^11.3.4", "vue-resource": "^0.9.3", "vue-template-compiler": "^2.2.6", - "webpack": "^2.3.3", - "webpack-bundle-analyzer": "^2.3.0" + "webpack": "^2.6.1", + "webpack-bundle-analyzer": "^2.8.2" }, "devDependencies": { "babel-plugin-istanbul": "^4.0.0", diff --git a/qa/Dockerfile b/qa/Dockerfile index 72c82503542..9e2a74ef991 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 6bfe25ba437..5d089a45934 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 6de2abff198..4dd71aa5010 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 d72187fcd34..78a93828d36 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 c07a3234673..64d06ef6558 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/activerecord_serialize.rb b/rubocop/cop/activerecord_serialize.rb new file mode 100644 index 00000000000..bfa0cff9a67 --- /dev/null +++ b/rubocop/cop/activerecord_serialize.rb @@ -0,0 +1,24 @@ +module RuboCop + module Cop + # Cop that prevents the use of `serialize` in ActiveRecord models. + class ActiverecordSerialize < RuboCop::Cop::Cop + MSG = 'Do not store serialized data in the database, use separate columns and/or tables instead'.freeze + + def on_send(node) + return unless in_models?(node) + + add_offense(node, :selector) if node.children[1] == :serialize + end + + def models_path + File.join(Dir.pwd, 'app', 'models') + end + + def in_models?(node) + path = node.location.expression.source_buffer.name + + path.start_with?(models_path) + end + end + end +end 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 00000000000..3f886cbfea3 --- /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 3160a784a04..c3473771178 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 4ff204f939e..17d2bf6aa1c 100644 --- a/rubocop/rubocop.rb +++ b/rubocop/rubocop.rb @@ -1,5 +1,6 @@ require_relative 'cop/custom_error_class' require_relative 'cop/gem_fetcher' +require_relative 'cop/activerecord_serialize' require_relative 'cop/migration/add_column' require_relative 'cop/migration/add_column_with_default_to_large_table' require_relative 'cop/migration/add_concurrent_foreign_key' @@ -8,3 +9,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 565bc314ef1..e4603533872 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 7d2f6dd9d0a..2c9d1ffc9c2 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 4626f1ebc29..b0b24b1de1b 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/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb index 010e3180ea4..0be7bc6a045 100644 --- a/spec/controllers/import/bitbucket_controller_spec.rb +++ b/spec/controllers/import/bitbucket_controller_spec.rb @@ -133,9 +133,13 @@ describe Import::BitbucketController do end context "when a namespace with the Bitbucket user's username already exists" do - let!(:existing_namespace) { create(:namespace, name: other_username, owner: user) } + let!(:existing_namespace) { create(:group, name: other_username) } context "when the namespace is owned by the GitLab user" do + before do + existing_namespace.add_owner(user) + end + it "takes the existing namespace" do expect(Gitlab::BitbucketImport::ProjectCreator). to receive(:new).with(bitbucket_repo, bitbucket_repo.name, existing_namespace, user, access_params). @@ -146,11 +150,6 @@ describe Import::BitbucketController do end context "when the namespace is not owned by the GitLab user" do - before do - existing_namespace.owner = create(:user) - existing_namespace.save - end - it "doesn't create a project" do expect(Gitlab::BitbucketImport::ProjectCreator). not_to receive(:new) @@ -202,10 +201,14 @@ describe Import::BitbucketController do end context 'user has chosen an existing nested namespace and name for the project' do - let(:parent_namespace) { create(:namespace, name: 'foo', owner: user) } - let(:nested_namespace) { create(:namespace, name: 'bar', parent: parent_namespace, owner: user) } + let(:parent_namespace) { create(:group, name: 'foo', owner: user) } + let(:nested_namespace) { create(:group, name: 'bar', parent: parent_namespace) } let(:test_name) { 'test_name' } + before do + nested_namespace.add_owner(user) + end + it 'takes the selected namespace and name' do expect(Gitlab::BitbucketImport::ProjectCreator). to receive(:new).with(bitbucket_repo, test_name, nested_namespace, user, access_params). @@ -248,7 +251,7 @@ describe Import::BitbucketController do context 'user has chosen existent and non-existent nested namespaces and name for the project' do let(:test_name) { 'test_name' } - let!(:parent_namespace) { create(:namespace, name: 'foo', owner: user) } + let!(:parent_namespace) { create(:group, name: 'foo', owner: user) } it 'takes the selected namespace and name' do expect(Gitlab::BitbucketImport::ProjectCreator). diff --git a/spec/controllers/import/gitlab_controller_spec.rb b/spec/controllers/import/gitlab_controller_spec.rb index 3270ea059fa..3afd09063d7 100644 --- a/spec/controllers/import/gitlab_controller_spec.rb +++ b/spec/controllers/import/gitlab_controller_spec.rb @@ -108,9 +108,13 @@ describe Import::GitlabController do end context "when a namespace with the GitLab.com user's username already exists" do - let!(:existing_namespace) { create(:namespace, name: other_username, owner: user) } + let!(:existing_namespace) { create(:group, name: other_username) } context "when the namespace is owned by the GitLab server user" do + before do + existing_namespace.add_owner(user) + end + it "takes the existing namespace" do expect(Gitlab::GitlabImport::ProjectCreator). to receive(:new).with(gitlab_repo, existing_namespace, user, access_params). @@ -121,11 +125,6 @@ describe Import::GitlabController do end context "when the namespace is not owned by the GitLab server user" do - before do - existing_namespace.owner = create(:user) - existing_namespace.save - end - it "doesn't create a project" do expect(Gitlab::GitlabImport::ProjectCreator). not_to receive(:new) @@ -176,8 +175,12 @@ describe Import::GitlabController do end context 'user has chosen an existing nested namespace for the project' do - let(:parent_namespace) { create(:namespace, name: 'foo', owner: user) } - let(:nested_namespace) { create(:namespace, name: 'bar', parent: parent_namespace, owner: user) } + let(:parent_namespace) { create(:group, name: 'foo', owner: user) } + let(:nested_namespace) { create(:group, name: 'bar', parent: parent_namespace) } + + before do + nested_namespace.add_owner(user) + end it 'takes the selected namespace and name' do expect(Gitlab::GitlabImport::ProjectCreator). @@ -221,7 +224,7 @@ describe Import::GitlabController do context 'user has chosen existent and non-existent nested namespaces and name for the project' do let(:test_name) { 'test_name' } - let!(:parent_namespace) { create(:namespace, name: 'foo', owner: user) } + let!(:parent_namespace) { create(:group, name: 'foo', owner: user) } it 'takes the selected namespace and name' do expect(Gitlab::GitlabImport::ProjectCreator). diff --git a/spec/controllers/profiles/keys_controller_spec.rb b/spec/controllers/profiles/keys_controller_spec.rb index 61e4fae46fb..363ed410bc0 100644 --- a/spec/controllers/profiles/keys_controller_spec.rb +++ b/spec/controllers/profiles/keys_controller_spec.rb @@ -49,7 +49,7 @@ describe Profiles::KeysController do expect(response.body).to eq(user.all_ssh_keys.join("\n")) expect(response.body).to include(key.key.sub(' dummy@gitlab.com', '')) - expect(response.body).to include(another_key.key) + expect(response.body).to include(another_key.key.sub(' dummy@gitlab.com', '')) expect(response.body).not_to include(deploy_key.key) end diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb index eff9fab8da2..428bc45b842 100644 --- a/spec/controllers/projects/artifacts_controller_spec.rb +++ b/spec/controllers/projects/artifacts_controller_spec.rb @@ -12,7 +12,7 @@ describe Projects::ArtifactsController do status: 'success') end - let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } + let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } before do project.team << [user, :developer] @@ -22,16 +22,16 @@ describe Projects::ArtifactsController do describe 'GET download' do it 'sends the artifacts file' do - expect(controller).to receive(:send_file).with(build.artifacts_file.path, disposition: 'attachment').and_call_original + expect(controller).to receive(:send_file).with(job.artifacts_file.path, disposition: 'attachment').and_call_original - get :download, namespace_id: project.namespace, project_id: project, build_id: build + get :download, namespace_id: project.namespace, project_id: project, job_id: job end end describe 'GET browse' do context 'when the directory exists' do it 'renders the browse view' do - get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'other_artifacts_0.1.2' + get :browse, namespace_id: project.namespace, project_id: project, job_id: job, path: 'other_artifacts_0.1.2' expect(response).to render_template('projects/artifacts/browse') end @@ -39,7 +39,7 @@ describe Projects::ArtifactsController do context 'when the directory does not exist' do it 'responds Not Found' do - get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + get :browse, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown' expect(response).to be_not_found end @@ -49,7 +49,7 @@ describe Projects::ArtifactsController do describe 'GET file' do context 'when the file exists' do it 'renders the file view' do - get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + get :file, namespace_id: project.namespace, project_id: project, job_id: job, path: 'ci_artifacts.txt' expect(response).to render_template('projects/artifacts/file') end @@ -57,7 +57,7 @@ describe Projects::ArtifactsController do context 'when the file does not exist' do it 'responds Not Found' do - get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + get :file, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown' expect(response).to be_not_found end @@ -67,7 +67,7 @@ describe Projects::ArtifactsController do describe 'GET raw' do context 'when the file exists' do it 'serves the file using workhorse' do - get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + get :raw, namespace_id: project.namespace, project_id: project, job_id: job, path: 'ci_artifacts.txt' send_data = response.headers[Gitlab::Workhorse::SEND_DATA_HEADER] @@ -84,7 +84,7 @@ describe Projects::ArtifactsController do context 'when the file does not exist' do it 'responds Not Found' do - get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + get :raw, namespace_id: project.namespace, project_id: project, job_id: job, path: 'unknown' expect(response).to be_not_found end @@ -92,29 +92,29 @@ describe Projects::ArtifactsController do end describe 'GET latest_succeeded' do - def params_from_ref(ref = pipeline.ref, job = build.name, path = 'browse') + def params_from_ref(ref = pipeline.ref, job_name = job.name, path = 'browse') { namespace_id: project.namespace, project_id: project, ref_name_and_path: File.join(ref, path), - job: job + job: job_name } end - context 'cannot find the build' do + context 'cannot find the job' do shared_examples 'not found' do it { expect(response).to have_http_status(:not_found) } end context 'has no such ref' do before do - get :latest_succeeded, params_from_ref('TAIL', build.name) + get :latest_succeeded, params_from_ref('TAIL', job.name) end it_behaves_like 'not found' end - context 'has no such build' do + context 'has no such job' do before do get :latest_succeeded, params_from_ref(pipeline.ref, 'NOBUILD') end @@ -124,20 +124,20 @@ describe Projects::ArtifactsController do context 'has no path' do before do - get :latest_succeeded, params_from_ref(pipeline.sha, build.name, '') + get :latest_succeeded, params_from_ref(pipeline.sha, job.name, '') end it_behaves_like 'not found' end end - context 'found the build and redirect' do - shared_examples 'redirect to the build' do + context 'found the job and redirect' do + shared_examples 'redirect to the job' do it 'redirects' do - path = browse_namespace_project_build_artifacts_path( + path = browse_namespace_project_job_artifacts_path( project.namespace, project, - build) + job) expect(response).to redirect_to(path) end @@ -151,7 +151,7 @@ describe Projects::ArtifactsController do get :latest_succeeded, params_from_ref('master') end - it_behaves_like 'redirect to the build' + it_behaves_like 'redirect to the job' end context 'with branch name containing slash' do @@ -162,7 +162,7 @@ describe Projects::ArtifactsController do get :latest_succeeded, params_from_ref('improve/awesome') end - it_behaves_like 'redirect to the build' + it_behaves_like 'redirect to the job' end context 'with branch name and path containing slashes' do @@ -170,14 +170,14 @@ describe Projects::ArtifactsController do pipeline.update(ref: 'improve/awesome', sha: project.commit('improve/awesome').sha) - get :latest_succeeded, params_from_ref('improve/awesome', build.name, 'file/README.md') + get :latest_succeeded, params_from_ref('improve/awesome', job.name, 'file/README.md') end it 'redirects' do - path = file_namespace_project_build_artifacts_path( + path = file_namespace_project_job_artifacts_path( project.namespace, project, - build, + job, 'README.md') expect(response).to redirect_to(path) diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index 20f99b209eb..f6840578145 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -57,6 +57,11 @@ describe Projects::EnvironmentsController do expect(json_response['available_count']).to eq 3 expect(json_response['stopped_count']).to eq 1 end + + it 'sets the polling interval header' do + expect(response).to have_http_status(:ok) + expect(response.headers['Poll-Interval']).to eq("3000") + end end context 'when requesting stopped environments scope' do @@ -177,7 +182,7 @@ describe Projects::EnvironmentsController do expect(response).to have_http_status(200) expect(json_response).to eq( { 'redirect_url' => - "http://test.host/#{project.path_with_namespace}/builds/#{action.id}" }) + namespace_project_job_url(project.namespace, project, action) }) end end @@ -191,7 +196,7 @@ describe Projects::EnvironmentsController do expect(response).to have_http_status(200) expect(json_response).to eq( { 'redirect_url' => - "http://test.host/#{project.path_with_namespace}/environments/#{environment.id}" }) + namespace_project_environment_url(project.namespace, project, environment) }) end end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 04afd07c59e..a38ae2eb990 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -204,7 +204,7 @@ describe Projects::IssuesController do body = JSON.parse(response.body) expect(body['assignees'].first.keys) - .to match_array(%w(id name username avatar_url)) + .to match_array(%w(id name username avatar_url state web_url)) end end diff --git a/spec/controllers/projects/builds_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index f41503fd34e..7211acc53dc 100644 --- a/spec/controllers/projects/builds_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Projects::BuildsController do +describe Projects::JobsController do include ApiHelpers let(:project) { create(:empty_project, :public) } @@ -101,26 +101,49 @@ describe Projects::BuildsController do end describe 'GET show' do - context 'when build exists' do - let!(:build) { create(:ci_build, pipeline: pipeline) } + let!(:build) { create(:ci_build, :failed, pipeline: pipeline) } - before do - get_show(id: build.id) + context 'when requesting HTML' do + context 'when build exists' do + before do + get_show(id: build.id) + end + + it 'has a build' do + expect(response).to have_http_status(:ok) + expect(assigns(:build).id).to eq(build.id) + end end - it 'has a build' do - expect(response).to have_http_status(:ok) - expect(assigns(:build).id).to eq(build.id) + context 'when build does not exist' do + before do + get_show(id: 1234) + end + + it 'renders not_found' do + expect(response).to have_http_status(:not_found) + end end end - context 'when build does not exist' do + context 'when requesting JSON' do + let(:merge_request) { create(:merge_request, source_project: project) } + before do - get_show(id: 1234) + project.add_developer(user) + sign_in(user) + + allow_any_instance_of(Ci::Build).to receive(:merge_request).and_return(merge_request) + + get_show(id: build.id, format: :json) end - it 'renders not_found' do - expect(response).to have_http_status(:not_found) + it 'exposes needed information' do + expect(response).to have_http_status(:ok) + expect(json_response['raw_path']).to match(/builds\/\d+\/raw\z/) + expect(json_response.dig('merge_request', 'path')).to match(/merge_requests\/\d+\z/) + expect(json_response['new_issue_path']) + .to include('/issues/new') end end @@ -213,7 +236,7 @@ describe Projects::BuildsController do it 'redirects to the retried build page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_build_path(id: Ci::Build.last.id)) + expect(response).to redirect_to(namespace_project_job_path(id: Ci::Build.last.id)) end end @@ -234,7 +257,11 @@ describe Projects::BuildsController do describe 'POST play' do before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) + sign_in(user) post_play @@ -245,7 +272,7 @@ describe Projects::BuildsController do it 'redirects to the played build page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_build_path(id: build.id)) + expect(response).to redirect_to(namespace_project_job_path(id: build.id)) end it 'transits to pending' do @@ -281,7 +308,7 @@ describe Projects::BuildsController do it 'redirects to the canceled build page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_build_path(id: build.id)) + expect(response).to redirect_to(namespace_project_job_path(id: build.id)) end it 'transits to canceled' do @@ -319,7 +346,7 @@ describe Projects::BuildsController do it 'redirects to a index page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_builds_path) + expect(response).to redirect_to(namespace_project_jobs_path) end it 'transits to canceled' do @@ -336,7 +363,7 @@ describe Projects::BuildsController do it 'redirects to a index page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_builds_path) + expect(response).to redirect_to(namespace_project_jobs_path) end end @@ -359,7 +386,7 @@ describe Projects::BuildsController do it 'redirects to the erased build page' do expect(response).to have_http_status(:found) - expect(response).to redirect_to(namespace_project_build_path(id: build.id)) + expect(response).to redirect_to(namespace_project_job_path(id: build.id)) end it 'erases artifacts' do diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 587a5820c6f..a25db7a65fb 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 @@ -127,7 +126,7 @@ describe Projects::MergeRequestsController do recorded = ActiveRecord::QueryRecorder.new { go(format: :json) } - expect(recorded.count).to be_within(5).of(50) + expect(recorded.count).to be_within(5).of(59) expect(recorded.cached_count).to eq(0) end end @@ -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/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb index 2d892f4a2b7..23b463c0082 100644 --- a/spec/controllers/projects/services_controller_spec.rb +++ b/spec/controllers/projects/services_controller_spec.rb @@ -3,7 +3,9 @@ require 'spec_helper' describe Projects::ServicesController do let(:project) { create(:project, :repository) } let(:user) { create(:user) } - let(:service) { create(:service, project: project) } + let(:service) { create(:hipchat_service, project: project) } + let(:hipchat_client) { { '#room' => double(send: true) } } + let(:service_params) { { token: 'hipchat_token_p', room: '#room' } } before do sign_in(user) @@ -13,97 +15,81 @@ describe Projects::ServicesController do controller.instance_variable_set(:@service, service) end - shared_examples_for 'services controller' do |referrer| - before do - request.env["HTTP_REFERER"] = referrer - end - - describe "#test" do - context 'when can_test? returns false' do - it 'renders 404' do - allow_any_instance_of(Service).to receive(:can_test?).and_return(false) + describe '#test' do + context 'when can_test? returns false' do + it 'renders 404' do + allow_any_instance_of(Service).to receive(:can_test?).and_return(false) - get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html + put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id - expect(response).to have_http_status(404) - end + expect(response).to have_http_status(404) end + end - context 'success' do - context 'with empty project' do - let(:project) { create(:empty_project) } - - context 'with chat notification service' do - let(:service) { project.create_microsoft_teams_service(webhook: 'http://webhook.com') } - - it 'redirects and show success message' do - allow_any_instance_of(MicrosoftTeams::Notifier).to receive(:ping).and_return(true) - - get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html + context 'success' do + context 'with empty project' do + let(:project) { create(:empty_project) } - expect(response).to redirect_to(root_path) - expect(flash[:notice]).to eq('We sent a request to the provided URL') - end - end + context 'with chat notification service' do + let(:service) { project.create_microsoft_teams_service(webhook: 'http://webhook.com') } - it 'redirects and show success message' do - expect(service).to receive(:test).and_return(success: true, result: 'done') + it 'returns success' do + allow_any_instance_of(MicrosoftTeams::Notifier).to receive(:ping).and_return(true) - get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html + put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id - expect(response).to redirect_to(root_path) - expect(flash[:notice]).to eq('We sent a request to the provided URL') + expect(response.status).to eq(200) end end - it "redirects and show success message" do - expect(service).to receive(:test).and_return(success: true, result: 'done') + it 'returns success' do + expect(HipChat::Client).to receive(:new).with('hipchat_token_p', anything).and_return(hipchat_client) - get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html + put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: service_params - expect(response).to redirect_to(root_path) - expect(flash[:notice]).to eq('We sent a request to the provided URL') + expect(response.status).to eq(200) end end - context 'failure' do - it "redirects and show failure message" do - expect(service).to receive(:test).and_return(success: false, result: 'Bad test') + it 'returns success' do + expect(HipChat::Client).to receive(:new).with('hipchat_token_p', anything).and_return(hipchat_client) - get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html + put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: service_params - expect(response).to redirect_to(root_path) - expect(flash[:alert]).to eq('We tried to send a request to the provided URL but an error occurred: Bad test') - end + expect(response.status).to eq(200) end end - end - describe 'referrer defined' do - it_should_behave_like 'services controller' do - let!(:referrer) { "/" } - end - end + context 'failure' do + it 'returns success status code and the error message' do + expect(HipChat::Client).to receive(:new).with('hipchat_token_p', anything).and_raise('Bad test') - describe 'referrer undefined' do - it_should_behave_like 'services controller' do - let!(:referrer) { nil } + put :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: service_params + + expect(response.status).to eq(200) + expect(JSON.parse(response.body)). + to eq('error' => true, 'message' => 'Test failed.', 'service_response' => 'Bad test') + end end end describe 'PUT #update' do - context 'on successful update' do - it 'sets the flash' do - expect(service).to receive(:to_param).and_return('hipchat') - expect(service).to receive(:event_names).and_return(HipchatService.event_names) + context 'when param `active` is set to true' do + it 'activates the service and redirects to integrations paths' do + put :update, + namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: { active: true } + + expect(response).to redirect_to(namespace_project_settings_integrations_path(project.namespace, project)) + expect(flash[:notice]).to eq 'HipChat activated.' + end + end + context 'when param `active` is set to false' do + it 'does not activate the service but saves the settings' do put :update, - namespace_id: project.namespace.id, - project_id: project.id, - id: service.id, - service: { active: false } + namespace_id: project.namespace.id, project_id: project.id, id: service.id, service: { active: false } - expect(flash[:notice]).to eq 'Successfully updated.' + expect(flash[:notice]).to eq 'HipChat settings saved, but not activated.' end end end diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index 038132cffe0..e87e24a33a1 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -1,6 +1,37 @@ require 'spec_helper' describe SessionsController do + describe '#new' do + before do + @request.env['devise.mapping'] = Devise.mappings[:user] + end + + context 'when auto sign-in is enabled' do + before do + stub_omniauth_setting(auto_sign_in_with_provider: :saml) + allow(controller).to receive(:omniauth_authorize_path).with(:user, :saml). + and_return('/saml') + end + + context 'and no auto_sign_in param is passed' do + it 'redirects to :omniauth_authorize_path' do + get(:new) + + expect(response).to have_http_status(302) + expect(response).to redirect_to('/saml') + end + end + + context 'and auto_sign_in=false param is passed' do + it 'responds with 200' do + get(:new, auto_sign_in: 'false') + + expect(response).to have_http_status(200) + end + end + end + end + describe '#create' do before do @request.env['devise.mapping'] = Devise.mappings[:user] diff --git a/spec/db/production/settings.rb b/spec/db/production/settings.rb index 007b35bbb77..3cbb173c4cc 100644 --- a/spec/db/production/settings.rb +++ b/spec/db/production/settings.rb @@ -1,5 +1,4 @@ require 'spec_helper' -require 'rainbow/ext/string' describe 'seed production settings', lib: true do include StubENV diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index f5e99fdf00b..0bb5a86d9b9 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -64,7 +64,8 @@ FactoryGirl.define do trait :teardown_environment do environment 'staging' options environment: { name: 'staging', - action: 'stop' } + action: 'stop', + url: 'http://staging.example.com/$CI_JOB_NAME' } end trait :allowed_to_fail do diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index 361c5b9a49e..35803f0c37f 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -1,5 +1,6 @@ FactoryGirl.define do factory :ci_empty_pipeline, class: Ci::Pipeline do + source :push ref 'master' sha '97de212e80737a608d939f648d959671fb0a0142' status 'pending' @@ -8,14 +9,14 @@ FactoryGirl.define do factory :ci_pipeline_without_jobs do after(:build) do |pipeline| - allow(pipeline).to receive(:ci_yaml_file) { YAML.dump({}) } + pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump({})) end end factory :ci_pipeline_with_one_job do after(:build) do |pipeline| allow(pipeline).to receive(:ci_yaml_file) do - YAML.dump({ rspec: { script: "ls" } }) + pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump({ rspec: { script: "ls" } })) end end end @@ -33,17 +34,14 @@ FactoryGirl.define do transient { config nil } after(:build) do |pipeline, evaluator| - allow(pipeline).to receive(:ci_yaml_file) do - if evaluator.config - YAML.dump(evaluator.config) - else - File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) - end - end + if evaluator.config + pipeline.instance_variable_set(:@ci_yaml_file, YAML.dump(evaluator.config)) - # Populates pipeline with errors - # - pipeline.config_processor if evaluator.config + # Populates pipeline with errors + pipeline.config_processor if evaluator.config + else + pipeline.instance_variable_set(:@ci_yaml_file, File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))) + end end trait :invalid do diff --git a/spec/factories/ci/stages.rb b/spec/factories/ci/stages.rb index 7f557b25ccb..d37eabb3e8c 100644 --- a/spec/factories/ci/stages.rb +++ b/spec/factories/ci/stages.rb @@ -1,5 +1,7 @@ FactoryGirl.define do factory :ci_stage, class: Ci::Stage do + skip_create + transient do name 'test' status nil diff --git a/spec/factories/ci/trigger_requests.rb b/spec/factories/ci/trigger_requests.rb index b8d8fab0e0b..10e0ab4fd3c 100644 --- a/spec/factories/ci/trigger_requests.rb +++ b/spec/factories/ci/trigger_requests.rb @@ -1,8 +1,8 @@ FactoryGirl.define do factory :ci_trigger_request, class: Ci::TriggerRequest do - factory :ci_trigger_request_with_variables do - trigger factory: :ci_trigger + trigger factory: :ci_trigger + factory :ci_trigger_request_with_variables do variables do { TRIGGER_KEY_1: 'TRIGGER_VALUE_1', diff --git a/spec/factories/ci/variables.rb b/spec/factories/ci/variables.rb index c5fba597c1c..f83366136fd 100644 --- a/spec/factories/ci/variables.rb +++ b/spec/factories/ci/variables.rb @@ -3,6 +3,10 @@ FactoryGirl.define do sequence(:key) { |n| "VARIABLE_#{n}" } value 'VARIABLE_VALUE' + trait(:protected) do + protected true + end + project factory: :empty_project end end diff --git a/spec/factories/commits.rb b/spec/factories/commits.rb index 89e260cf65b..36b9645438a 100644 --- a/spec/factories/commits.rb +++ b/spec/factories/commits.rb @@ -4,19 +4,14 @@ FactoryGirl.define do factory :commit do git_commit RepoHelpers.sample_commit project factory: :empty_project + author { build(:author) } initialize_with do new(git_commit, project) end - after(:build) do |commit| - allow(commit).to receive(:author).and_return build(:author) - end - trait :without_author do - after(:build) do |commit| - allow(commit).to receive(:author).and_return nil - end + author nil end end end diff --git a/spec/factories/conversational_development_index_metrics.rb b/spec/factories/conversational_development_index_metrics.rb new file mode 100644 index 00000000000..a5412629195 --- /dev/null +++ b/spec/factories/conversational_development_index_metrics.rb @@ -0,0 +1,33 @@ +FactoryGirl.define do + factory :conversational_development_index_metric, class: ConversationalDevelopmentIndex::Metric do + leader_issues 9.256 + instance_issues 1.234 + + leader_notes 30.33333 + instance_notes 28.123 + + leader_milestones 16.2456 + instance_milestones 1.234 + + leader_boards 5.2123 + instance_boards 3.254 + + leader_merge_requests 1.2 + instance_merge_requests 0.6 + + leader_ci_pipelines 12.1234 + instance_ci_pipelines 2.344 + + leader_environments 3.3333 + instance_environments 2.2222 + + leader_deployments 1.200 + instance_deployments 0.771 + + leader_projects_prometheus_active 0.111 + instance_projects_prometheus_active 0.109 + + leader_service_desk_issues 15.891 + instance_service_desk_issues 13.345 + end +end diff --git a/spec/factories/file_uploader.rb b/spec/factories/file_uploaders.rb index bc74aeecc3b..d397dd705a5 100644 --- a/spec/factories/file_uploader.rb +++ b/spec/factories/file_uploaders.rb @@ -1,5 +1,7 @@ FactoryGirl.define do factory :file_uploader do + skip_create + project factory: :empty_project secret nil diff --git a/spec/factories/keys.rb b/spec/factories/keys.rb index 4e140102492..a13b6e3596e 100644 --- a/spec/factories/keys.rb +++ b/spec/factories/keys.rb @@ -1,27 +1,18 @@ +require_relative '../support/helpers/key_generator_helper' + FactoryGirl.define do factory :key do title - key do - 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt4596k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0= dummy@gitlab.com' - end + key { Spec::Support::Helpers::KeyGeneratorHelper.new(1024).generate + ' dummy@gitlab.com' } - factory :deploy_key, class: 'DeployKey' do - key do - 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFf6RYK3qu/RKF/3ndJmL5xgMLp3O96x8lTay+QGZ0+9FnnAXMdUqBq/ZU6d/gyMB4IaW3nHzM1w049++yAB6UPCzMB8Uo27K5/jyZCtj7Vm9PFNjF/8am1kp46c/SeYicQgQaSBdzIW3UDEa1Ef68qroOlvpi9PYZ/tA7M0YP0K5PXX+E36zaIRnJVMPT3f2k+GnrxtjafZrwFdpOP/Fol5BQLBgcsyiU+LM1SuaCrzd8c9vyaTA1CxrkxaZh+buAi0PmdDtaDrHd42gqZkXCKavyvgM5o2CkQ5LJHCgzpXy05qNFzmThBSkb+XtoxbyagBiGbVZtSVow6Xa7qewz' - end - end + factory :deploy_key, class: 'DeployKey' factory :personal_key do user end factory :another_key do - key do - 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDmTillFzNTrrGgwaCKaSj+QCz81E6jBc/s9av0+3b1Hwfxgkqjl4nAK/OD2NjgyrONDTDfR8cRN4eAAy6nY8GLkOyYBDyuc5nTMqs5z3yVuTwf3koGm/YQQCmo91psZ2BgDFTor8SVEE5Mm1D1k3JDMhDFxzzrOtRYFPci9lskTJaBjpqWZ4E9rDTD2q/QZntCqbC3wE9uSemRQB5f8kik7vD/AD8VQXuzKladrZKkzkONCPWsXDspUitjM8HkQdOf0PsYn1CMUC1xKYbCxkg5TkEosIwGv6CoEArUrdu/4+10LVslq494mAvEItywzrluCLCnwELfW+h/m8UHoVhZ' - end - - factory :another_deploy_key, class: 'DeployKey' do - end + factory :another_deploy_key, class: 'DeployKey' end factory :write_access_key, class: 'DeployKey' do diff --git a/spec/factories/project_statistics.rb b/spec/factories/project_statistics.rb index 72d43096216..6c2ed7c6581 100644 --- a/spec/factories/project_statistics.rb +++ b/spec/factories/project_statistics.rb @@ -1,6 +1,10 @@ FactoryGirl.define do factory :project_statistics do - project { create :project } - namespace { project.namespace } + project + + initialize_with do + # statistics are automatically created when a project is created + project&.statistics || new + end end end diff --git a/spec/factories/project_wikis.rb b/spec/factories/project_wikis.rb index a3403fd76ae..ae222d5e69a 100644 --- a/spec/factories/project_wikis.rb +++ b/spec/factories/project_wikis.rb @@ -1,5 +1,7 @@ FactoryGirl.define do factory :project_wiki do + skip_create + project factory: :empty_project user factory: :user initialize_with { new(project, user) } diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 7a76f5f8afc..19a85e5a38f 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -1,3 +1,5 @@ +require_relative '../support/test_env' + FactoryGirl.define do # Project without repository # @@ -109,6 +111,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/factories/services.rb b/spec/factories/services.rb index 28ddd0da753..e7366a7fd1c 100644 --- a/spec/factories/services.rb +++ b/spec/factories/services.rb @@ -20,7 +20,6 @@ FactoryGirl.define do project factory: :empty_project active true properties({ - namespace: 'somepath', api_url: 'https://kubernetes.example.com', token: 'a' * 40 }) @@ -34,4 +33,10 @@ FactoryGirl.define do project_key: 'jira-key' ) end + + factory :hipchat_service do + project factory: :empty_project + type 'HipchatService' + token 'test_token' + end end diff --git a/spec/factories/web_hook_log.rb b/spec/factories/web_hook_log.rb new file mode 100644 index 00000000000..230b3f6b26e --- /dev/null +++ b/spec/factories/web_hook_log.rb @@ -0,0 +1,14 @@ +FactoryGirl.define do + factory :web_hook_log do + web_hook factory: :project_hook + trigger 'push_hooks' + url { generate(:url) } + request_headers {} + request_data {} + response_headers {} + response_body '' + response_status '200' + execution_duration 2.0 + internal_error_message nil + end +end diff --git a/spec/factories/wiki_directories.rb b/spec/factories/wiki_directories.rb index 3f3c864ac2b..3b4cfc380b8 100644 --- a/spec/factories/wiki_directories.rb +++ b/spec/factories/wiki_directories.rb @@ -1,5 +1,7 @@ FactoryGirl.define do factory :wiki_directory do + skip_create + slug '/path_up_to/dir' initialize_with { new(slug) } end diff --git a/spec/factories_spec.rb b/spec/factories_spec.rb index 786e1456f5f..09b3c0b0994 100644 --- a/spec/factories_spec.rb +++ b/spec/factories_spec.rb @@ -3,14 +3,20 @@ require 'spec_helper' describe 'factories' do FactoryGirl.factories.each do |factory| describe "#{factory.name} factory" do - let(:entity) { build(factory.name) } + it 'does not raise error when built' do + expect { build(factory.name) }.not_to raise_error + end it 'does not raise error when created' do - expect { entity }.not_to raise_error + expect { create(factory.name) }.not_to raise_error end - it 'is valid', if: factory.build_class < ActiveRecord::Base do - expect(entity).to be_valid + factory.definition.defined_traits.map(&:name).each do |trait_name| + describe "linting #{trait_name} trait" do + skip 'does not raise error when created' do + expect { create(factory.name, trait_name) }.not_to raise_error + end + end end end end diff --git a/spec/features/admin/admin_builds_spec.rb b/spec/features/admin/admin_builds_spec.rb index 9d5ce876c29..999ce3611b5 100644 --- a/spec/features/admin/admin_builds_spec.rb +++ b/spec/features/admin/admin_builds_spec.rb @@ -16,7 +16,7 @@ describe 'Admin Builds' do create(:ci_build, pipeline: pipeline, status: :success) create(:ci_build, pipeline: pipeline, status: :failed) - visit admin_builds_path + visit admin_jobs_path expect(page).to have_selector('.nav-links li.active', text: 'All') expect(page).to have_selector('.row-content-block', text: 'All jobs') @@ -27,7 +27,7 @@ describe 'Admin Builds' do context 'when have no jobs' do it 'shows a message' do - visit admin_builds_path + visit admin_jobs_path expect(page).to have_selector('.nav-links li.active', text: 'All') expect(page).to have_content 'No jobs to show' @@ -44,7 +44,7 @@ describe 'Admin Builds' do build3 = create(:ci_build, pipeline: pipeline, status: :success) build4 = create(:ci_build, pipeline: pipeline, status: :failed) - visit admin_builds_path(scope: :pending) + visit admin_jobs_path(scope: :pending) expect(page).to have_selector('.nav-links li.active', text: 'Pending') expect(page.find('.build-link')).to have_content(build1.id) @@ -59,7 +59,7 @@ describe 'Admin Builds' do it 'shows a message' do create(:ci_build, pipeline: pipeline, status: :success) - visit admin_builds_path(scope: :pending) + visit admin_jobs_path(scope: :pending) expect(page).to have_selector('.nav-links li.active', text: 'Pending') expect(page).to have_content 'No jobs to show' @@ -76,7 +76,7 @@ describe 'Admin Builds' do build3 = create(:ci_build, pipeline: pipeline, status: :failed) build4 = create(:ci_build, pipeline: pipeline, status: :pending) - visit admin_builds_path(scope: :running) + visit admin_jobs_path(scope: :running) expect(page).to have_selector('.nav-links li.active', text: 'Running') expect(page.find('.build-link')).to have_content(build1.id) @@ -91,7 +91,7 @@ describe 'Admin Builds' do it 'shows a message' do create(:ci_build, pipeline: pipeline, status: :success) - visit admin_builds_path(scope: :running) + visit admin_jobs_path(scope: :running) expect(page).to have_selector('.nav-links li.active', text: 'Running') expect(page).to have_content 'No jobs to show' @@ -107,7 +107,7 @@ describe 'Admin Builds' do build2 = create(:ci_build, pipeline: pipeline, status: :running) build3 = create(:ci_build, pipeline: pipeline, status: :success) - visit admin_builds_path(scope: :finished) + visit admin_jobs_path(scope: :finished) expect(page).to have_selector('.nav-links li.active', text: 'Finished') expect(page.find('.build-link')).not_to have_content(build1.id) @@ -121,7 +121,7 @@ describe 'Admin Builds' do it 'shows a message' do create(:ci_build, pipeline: pipeline, status: :running) - visit admin_builds_path(scope: :finished) + visit admin_jobs_path(scope: :finished) expect(page).to have_selector('.nav-links li.active', text: 'Finished') expect(page).to have_content 'No jobs to show' diff --git a/spec/features/admin/admin_conversational_development_index_spec.rb b/spec/features/admin/admin_conversational_development_index_spec.rb new file mode 100644 index 00000000000..739ab907a29 --- /dev/null +++ b/spec/features/admin/admin_conversational_development_index_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe 'Admin Conversational Development Index' do + before do + login_as :admin + end + + context 'when usage ping is disabled' do + it 'shows empty state' do + stub_application_setting(usage_ping_enabled: false) + + visit admin_conversational_development_index_path + + expect(page).to have_content('Usage ping is not enabled') + end + end + + context 'when there is no data to display' do + it 'shows empty state' do + stub_application_setting(usage_ping_enabled: true) + + visit admin_conversational_development_index_path + + expect(page).to have_content('Data is still calculating') + end + end + + context 'when there is data to display' do + it 'shows numbers for each metric' do + stub_application_setting(usage_ping_enabled: true) + create(:conversational_development_index_metric) + + visit admin_conversational_development_index_path + + expect(page).to have_content( + 'Issues created per active user 1.2 You 9.3 Lead 13.3%' + ) + end + 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 273cacd82cd..e8e080ce3e2 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_hook_logs_spec.rb b/spec/features/admin/admin_hook_logs_spec.rb new file mode 100644 index 00000000000..5b67f4de6ac --- /dev/null +++ b/spec/features/admin/admin_hook_logs_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +feature 'Admin::HookLogs', feature: true do + let(:project) { create(:project) } + let(:system_hook) { create(:system_hook) } + let(:hook_log) { create(:web_hook_log, web_hook: system_hook, internal_error_message: 'some error') } + + before do + login_as :admin + end + + scenario 'show list of hook logs' do + hook_log + visit edit_admin_hook_path(system_hook) + + expect(page).to have_content('Recent Deliveries') + expect(page).to have_content(hook_log.url) + end + + scenario 'show hook log details' do + hook_log + visit edit_admin_hook_path(system_hook) + click_link 'View details' + + expect(page).to have_content("POST #{hook_log.url}") + expect(page).to have_content(hook_log.internal_error_message) + expect(page).to have_content('Resend Request') + end + + scenario 'retry hook log' do + WebMock.stub_request(:post, system_hook.url) + + hook_log + visit edit_admin_hook_path(system_hook) + click_link 'View details' + click_link 'Resend Request' + + expect(current_path).to eq(edit_admin_hook_path(system_hook)) + end +end diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb index c5f24d412d7..80f7ec43c06 100644 --- a/spec/features/admin/admin_hooks_spec.rb +++ b/spec/features/admin/admin_hooks_spec.rb @@ -58,10 +58,19 @@ describe 'Admin::Hooks', feature: true do end describe 'Remove existing hook' do - it 'remove existing hook' do - visit admin_hooks_path + context 'removes existing hook' do + it 'from hooks list page' do + visit admin_hooks_path + + expect { click_link 'Remove' }.to change(SystemHook, :count).by(-1) + end - expect { click_link 'Remove' }.to change(SystemHook, :count).by(-1) + it 'from hook edit page' do + visit admin_hooks_path + click_link 'Edit' + + expect { click_link 'Remove' }.to change(SystemHook, :count).by(-1) + end end end diff --git a/spec/features/admin/admin_system_info_spec.rb b/spec/features/admin/admin_system_info_spec.rb index 1df972843e2..15482347886 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/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb index 12cf59f42b0..376e80571d0 100644 --- a/spec/features/admin/admin_users_spec.rb +++ b/spec/features/admin/admin_users_spec.rb @@ -21,6 +21,8 @@ describe "Admin::Users", feature: true do expect(page).to have_content(current_user.name) expect(page).to have_content(user.email) expect(page).to have_content(user.name) + expect(page).to have_link('Block', href: block_admin_user_path(user)) + expect(page).to have_link('Delete', href: admin_user_path(user)) end describe 'Two-factor Authentication filters' do diff --git a/spec/features/boards/modal_filter_spec.rb b/spec/features/boards/modal_filter_spec.rb index ce132bfd979..b6de6143354 100644 --- a/spec/features/boards/modal_filter_spec.rb +++ b/spec/features/boards/modal_filter_spec.rb @@ -89,7 +89,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do page.within('.add-issues-modal') do wait_for_requests - expect(page).to have_selector('.js-visual-token', text: user2.username) + expect(page).to have_selector('.js-visual-token', text: user2.name) expect(page).to have_selector('.card', count: 1) end end @@ -125,7 +125,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do page.within('.add-issues-modal') do wait_for_requests - expect(page).to have_selector('.js-visual-token', text: user2.username) + expect(page).to have_selector('.js-visual-token', text: user2.name) expect(page).to have_selector('.card', count: 1) end end diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index e6c4ab24de5..2772f05982a 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -76,7 +76,7 @@ describe 'Commits' do end end - describe 'Commit builds' do + describe 'Commit builds', :feature, :js do before do visit ci_status_path(pipeline) end @@ -85,7 +85,6 @@ describe 'Commits' do expect(page).to have_content pipeline.sha[0..7] expect(page).to have_content pipeline.git_commit_message expect(page).to have_content pipeline.user.name - expect(page).to have_content pipeline.created_at.strftime('%b %d, %Y') end end @@ -102,7 +101,7 @@ describe 'Commits' do end describe 'Cancel all builds' do - it 'cancels commit' do + it 'cancels commit', :js do visit ci_status_path(pipeline) click_on 'Cancel running' expect(page).to have_content 'canceled' @@ -110,9 +109,9 @@ describe 'Commits' do end describe 'Cancel build' do - it 'cancels build' do + it 'cancels build', :js do visit ci_status_path(pipeline) - find('a.btn[title="Cancel"]').click + find('.js-btn-cancel-pipeline').click expect(page).to have_content 'canceled' end end @@ -152,17 +151,20 @@ describe 'Commits' do visit ci_status_path(pipeline) end - it do + it 'Renders header', :feature, :js do expect(page).to have_content pipeline.sha[0..7] expect(page).to have_content pipeline.git_commit_message expect(page).to have_content pipeline.user.name - expect(page).to have_link('Download artifacts') expect(page).not_to have_link('Cancel running') expect(page).not_to have_link('Retry') end + + it do + expect(page).to have_link('Download artifacts') + end end - context 'when accessing internal project with disallowed access' do + context 'when accessing internal project with disallowed access', :feature, :js do before do project.update( visibility_level: Gitlab::VisibilityLevel::INTERNAL, @@ -175,7 +177,7 @@ describe 'Commits' do expect(page).to have_content pipeline.sha[0..7] expect(page).to have_content pipeline.git_commit_message expect(page).to have_content pipeline.user.name - expect(page).not_to have_link('Download artifacts') + expect(page).not_to have_link('Cancel running') expect(page).not_to have_link('Retry') end diff --git a/spec/features/container_registry_spec.rb b/spec/features/container_registry_spec.rb index b86609e07c5..fa7adbe71ea 100644 --- a/spec/features/container_registry_spec.rb +++ b/spec/features/container_registry_spec.rb @@ -19,7 +19,7 @@ describe "Container Registry" do scenario 'user visits container registry main page' do visit_container_registry - expect(page).to have_content 'No container image repositories' + expect(page).to have_content 'No container images' end end diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb index 2346a9ec2ed..2cea6b1563e 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 508ca38d7e5..9cebe52c444 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 01351548a99..fa3435ab719 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 8a1d415c4f1..dfc3c84f29a 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 543879bd21d..f654fa16a06 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 3d32c47bf09..24ea7aba0cc 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 0b573d7cef4..44353d880c2 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 @@ -157,6 +157,25 @@ describe 'Dropdown assignee', :feature, :js do end end + describe 'selecting from dropdown without Ajax call' do + before do + Gitlab::Testing::RequestBlockerMiddleware.block_requests! + filtered_search.set('assignee:') + end + + after do + Gitlab::Testing::RequestBlockerMiddleware.allow_requests! + end + + it 'selects current user' do + find('#js-dropdown-assignee .filter-dropdown-item', text: user.username).click + + expect(page).to have_css(js_dropdown_assignee, visible: false) + expect_tokens([{ name: 'assignee', value: user.username }]) + expect_filtered_search_input_empty + end + end + describe 'input has existing content' do it 'opens assignee dropdown with existing search term' do filtered_search.set('searchTerm assignee:') diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb index b29177bed06..6b707c4be4a 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 @@ -135,6 +135,25 @@ describe 'Dropdown author', js: true, feature: true do end end + describe 'selecting from dropdown without Ajax call' do + before do + Gitlab::Testing::RequestBlockerMiddleware.block_requests! + filtered_search.set('author:') + end + + after do + Gitlab::Testing::RequestBlockerMiddleware.allow_requests! + end + + it 'selects current user' do + find('#js-dropdown-author .filter-dropdown-item', text: user.username).click + + expect(page).to have_css(js_dropdown_author, visible: false) + expect_tokens([{ name: 'author', value: user.username }]) + expect_filtered_search_input_empty + end + end + describe 'input has existing content' do it 'opens author dropdown with existing search term' do filtered_search.set('searchTerm author:') diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index 7958ad7e24f..e5e4ba06b5a 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -6,7 +6,7 @@ describe 'Filter issues', js: true, feature: true do let!(:group) { create(:group) } let!(:project) { create(:project, group: group) } - let!(:user) { create(:user, username: 'joe') } + let!(:user) { create(:user, username: 'joe', name: 'Joe') } let!(:user2) { create(:user, username: 'jane') } let!(:label) { create(:label, project: project) } let!(:wontfix) { create(:label, project: project, title: "Won't fix") } diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb index 96e87c82d2c..dbbafc9e004 100644 --- a/spec/features/issues/filtered_search/visual_tokens_spec.rb +++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb @@ -2,6 +2,7 @@ require 'rails_helper' describe 'Visual tokens', js: true, feature: true do include FilteredSearchHelpers + include WaitForRequests let!(:project) { create(:empty_project) } let!(:user) { create(:user, name: 'administrator', username: 'root') } @@ -70,7 +71,8 @@ describe 'Visual tokens', js: true, feature: true do end it 'changes value in visual token' do - expect(first('.tokens-container .filtered-search-token .value').text).to eq("@#{user_rock.username}") + wait_for_requests + expect(first('.tokens-container .filtered-search-token .value').text).to eq("#{user_rock.name}") end it 'moves input to the right' do diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index 65d854d0896..8949dbcb663 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 99ad8013023..96c24750250 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/issues/move_spec.rb b/spec/features/issues/move_spec.rb index 6c09903a2f6..e75bf059218 100644 --- a/spec/features/issues/move_spec.rb +++ b/spec/features/issues/move_spec.rb @@ -38,9 +38,11 @@ feature 'issue move to another project' do end scenario 'moving issue to another project', js: true do - find('#move_to_project_id', visible: false).set(new_project.id) + find('#issuable-move', visible: false).set(new_project.id) click_button('Save changes') + wait_for_requests + expect(current_url).to include project_path(new_project) expect(page).to have_content("Text with #{cross_reference}#{mr.to_reference}") @@ -51,7 +53,7 @@ feature 'issue move to another project' do scenario 'searching project dropdown', js: true do new_project_search.team << [user, :reporter] - page.within '.js-move-dropdown' do + page.within '.detail-page-description' do first('.select2-choice').click end @@ -69,7 +71,7 @@ feature 'issue move to another project' do background { another_project.team << [user, :guest] } scenario 'browsing projects in projects select' do - click_link 'Select project' + click_link 'Move to a different project' page.within '.select2-results' do expect(page).to have_content 'No project' diff --git a/spec/features/merge_requests/discussion_spec.rb b/spec/features/merge_requests/discussion_spec.rb index 1a09cc54c2e..9db235f35ba 100644 --- a/spec/features/merge_requests/discussion_spec.rb +++ b/spec/features/merge_requests/discussion_spec.rb @@ -5,7 +5,7 @@ feature 'Merge Request Discussions', feature: true do login_as :admin end - context "Diff discussions" do + describe "Diff discussions" do let(:merge_request) { create(:merge_request, importing: true) } let(:project) { merge_request.source_project } let!(:old_merge_request_diff) { merge_request.merge_request_diffs.create(diff_refs: outdated_diff_refs) } @@ -48,4 +48,43 @@ feature 'Merge Request Discussions', feature: true do end end end + + describe 'Commit comments displayed in MR context', :js do + let(:merge_request) { create(:merge_request) } + let(:project) { merge_request.project } + + shared_examples 'a functional discussion' do + let(:discussion_id) { note.discussion_id(merge_request) } + + it 'is displayed' do + expect(page).to have_css(".discussion[data-discussion-id='#{discussion_id}']") + end + + it 'can be replied to' do + within(".discussion[data-discussion-id='#{discussion_id}']") do + click_button 'Reply...' + fill_in 'note[note]', with: 'Test!' + click_button 'Comment' + + expect(page).to have_css('.note', count: 2) + end + end + end + + before(:each) do + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + context 'a regular commit comment' do + let(:note) { create(:note_on_commit, project: project) } + + it_behaves_like 'a functional discussion' + end + + context 'a commit diff comment' do + let(:note) { create(:diff_note_on_commit, project: project) } + + it_behaves_like 'a functional discussion' + end + end end diff --git a/spec/features/merge_requests/edit_mr_spec.rb b/spec/features/merge_requests/edit_mr_spec.rb index ec87a99b3ab..c77a5c68bc6 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 e08721b4724..09f889d4dd6 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/mini_pipeline_graph_spec.rb b/spec/features/merge_requests/mini_pipeline_graph_spec.rb index 51e7467c14c..3ceb91d951d 100644 --- a/spec/features/merge_requests/mini_pipeline_graph_spec.rb +++ b/spec/features/merge_requests/mini_pipeline_graph_spec.rb @@ -85,7 +85,7 @@ feature 'Mini Pipeline Graph', :js, :feature do build_item.click find('.build-page') - expect(current_path).to eql(namespace_project_build_path(project.namespace, project, build)) + expect(current_path).to eql(namespace_project_job_path(project.namespace, project, build)) end it 'should show tooltip when hovered' do diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb index be8b1423c20..4f3a5119915 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/artifacts/browse_spec.rb b/spec/features/projects/artifacts/browse_spec.rb new file mode 100644 index 00000000000..68375956273 --- /dev/null +++ b/spec/features/projects/artifacts/browse_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +feature 'Browse artifact', :js, feature: true do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } + let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } + + def browse_path(path) + browse_namespace_project_job_artifacts_path(project.namespace, project, job, path) + end + + context 'when visiting old URL' do + let(:browse_url) do + browse_path('other_artifacts_0.1.2') + end + + before do + visit browse_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(browse_url) + end + end +end diff --git a/spec/features/projects/artifacts/download_spec.rb b/spec/features/projects/artifacts/download_spec.rb new file mode 100644 index 00000000000..dd9454840ee --- /dev/null +++ b/spec/features/projects/artifacts/download_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +feature 'Download artifact', :js, feature: true do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, status: :success, project: project, sha: project.commit.sha, ref: 'master') } + let(:job) { create(:ci_build, :artifacts, :success, pipeline: pipeline) } + + shared_examples 'downloading' do + it 'downloads the zip' do + expect(page.response_headers['Content-Disposition']) + .to eq(%Q{attachment; filename="#{job.artifacts_file.filename}"}) + + # Check the content does match, but don't print this as error message + expect(page.source.b == job.artifacts_file.file.read.b) + end + end + + context 'when downloading' do + before do + visit download_url + end + + context 'via job id' do + let(:download_url) do + download_namespace_project_job_artifacts_path(project.namespace, project, job) + end + + it_behaves_like 'downloading' + end + + context 'via branch name and job name' do + let(:download_url) do + latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{pipeline.ref}/download", job: job.name) + end + + it_behaves_like 'downloading' + end + end + + context 'when visiting old URL' do + before do + visit download_url.sub('/-/jobs', '/builds') + end + + context 'via job id' do + let(:download_url) do + download_namespace_project_job_artifacts_path(project.namespace, project, job) + end + + it_behaves_like 'downloading' + end + + context 'via branch name and job name' do + let(:download_url) do + latest_succeeded_namespace_project_artifacts_path(project.namespace, project, "#{pipeline.ref}/download", job: job.name) + end + + it_behaves_like 'downloading' + end + end +end diff --git a/spec/features/projects/artifacts/file_spec.rb b/spec/features/projects/artifacts/file_spec.rb index 25db908d917..25c4f3c87a2 100644 --- a/spec/features/projects/artifacts/file_spec.rb +++ b/spec/features/projects/artifacts/file_spec.rb @@ -6,7 +6,11 @@ feature 'Artifact file', :js, feature: true do let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } def visit_file(path) - visit file_namespace_project_build_artifacts_path(project.namespace, project, build, path) + visit file_path(path) + end + + def file_path(path) + file_namespace_project_job_artifacts_path(project.namespace, project, build, path) end context 'Text file' do @@ -56,4 +60,18 @@ feature 'Artifact file', :js, feature: true do end end end + + context 'when visiting old URL' do + let(:file_url) do + file_path('other_artifacts_0.1.2/doc_sample.txt') + end + + before do + visit file_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(file_url) + end + end end diff --git a/spec/features/projects/artifacts/raw_spec.rb b/spec/features/projects/artifacts/raw_spec.rb new file mode 100644 index 00000000000..b589701729d --- /dev/null +++ b/spec/features/projects/artifacts/raw_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +feature 'Raw artifact', :js, feature: true do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } + let(:job) { create(:ci_build, :artifacts, pipeline: pipeline) } + + def raw_path(path) + raw_namespace_project_job_artifacts_path(project.namespace, project, job, path) + end + + context 'when visiting old URL' do + let(:raw_url) do + raw_path('other_artifacts_0.1.2/doc_sample.txt') + end + + before do + visit raw_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(raw_url) + end + end +end diff --git a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb index d94204230f6..53c5a52ce3a 100644 --- a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb +++ b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb @@ -55,7 +55,7 @@ feature 'Blob button line permalinks (BlobLinePermalinkUpdater)', feature: true, end end - describe 'Click "Blame" button' do + describe 'Click "Annotate" button' do it 'works with no initial line number fragment hash' do visit_blob diff --git a/spec/features/projects/compare_spec.rb b/spec/features/projects/compare_spec.rb index 294a63a5c6d..4162f2579d1 100644 --- a/spec/features/projects/compare_spec.rb +++ b/spec/features/projects/compare_spec.rb @@ -52,8 +52,12 @@ describe "Compare", js: true do def select_using_dropdown(dropdown_type, selection) dropdown = find(".js-compare-#{dropdown_type}-dropdown") dropdown.find(".compare-dropdown-toggle").click + # find input before using to wait for the inputs visiblity + dropdown.find('.dropdown-menu') dropdown.fill_in("Filter by Git revision", with: selection) wait_for_requests - dropdown.find_all("a[data-ref=\"#{selection}\"]", visible: true).last.click + # find before all to wait for the items visiblity + dropdown.find("a[data-ref=\"#{selection}\"]", match: :first) + dropdown.all("a[data-ref=\"#{selection}\"]").last.click 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 2352329d58c..0c51fe72ca4 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/environments/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index 86ce50c976f..18b608c863e 100644 --- a/spec/features/projects/environments/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -12,6 +12,7 @@ feature 'Environment', :feature do feature 'environment details page' do given!(:environment) { create(:environment, project: project) } + given!(:permissions) { } given!(:deployment) { } given!(:action) { } @@ -62,20 +63,31 @@ feature 'Environment', :feature do name: 'deploy to production') end - given(:role) { :master } + context 'when user has ability to trigger deployment' do + given(:permissions) do + create(:protected_branch, :developers_can_merge, + name: action.ref, project: project) + end - scenario 'does show a play button' do - expect(page).to have_link(action.name.humanize) - end + it 'does show a play button' do + expect(page).to have_link(action.name.humanize) + end + + it 'does allow to play manual action' do + expect(action).to be_manual - scenario 'does allow to play manual action' do - expect(action).to be_manual + expect { click_link(action.name.humanize) } + .not_to change { Ci::Pipeline.count } - expect { click_link(action.name.humanize) } - .not_to change { Ci::Pipeline.count } + expect(page).to have_content(action.name) + expect(action.reload).to be_pending + end + end - expect(page).to have_content(action.name) - expect(action.reload).to be_pending + context 'when user has no ability to trigger a deployment' do + it 'does not show a play button' do + expect(page).not_to have_link(action.name.humanize) + end end context 'with external_url' do @@ -134,12 +146,23 @@ feature 'Environment', :feature do on_stop: 'close_app') end - given(:role) { :master } + context 'when user has ability to stop environment' do + given(:permissions) do + create(:protected_branch, :developers_can_merge, + name: action.ref, project: project) + end - scenario 'does allow to stop environment' do - click_link('Stop') + it 'allows to stop environment' do + click_link('Stop') - expect(page).to have_content('close_app') + expect(page).to have_content('close_app') + end + end + + context 'when user has no ability to stop environment' do + it 'does not allow to stop environment' do + expect(page).to have_no_link('Stop') + end end context 'for reporter' do @@ -150,12 +173,6 @@ feature 'Environment', :feature do end end end - - context 'without stop action' do - scenario 'does allow to stop environment' do - click_link('Stop') - end - end end context 'when environment is stopped' do diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index cf393afccbb..31345403702 100644 --- a/spec/features/projects/environments/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -239,7 +239,9 @@ feature 'Environments page', :feature, :js do context 'when logged as developer' do before do - click_link 'New environment' + within(".top-area") do + click_link 'New environment' + end end context 'for valid name' do diff --git a/spec/features/projects/files/browse_files_spec.rb b/spec/features/projects/files/browse_files_spec.rb index c0a9327249c..30a1eedbb48 100644 --- a/spec/features/projects/files/browse_files_spec.rb +++ b/spec/features/projects/files/browse_files_spec.rb @@ -12,7 +12,7 @@ feature 'user browses project', feature: true, js: true do scenario "can see blame of '.gitignore'" do click_link ".gitignore" - click_link 'Blame' + click_link 'Annotate' expect(page).to have_content "*.rb" expect(page).to have_content "Dmitriy Zaporozhets" diff --git a/spec/features/projects/group_links_spec.rb b/spec/features/projects/group_links_spec.rb index c969acc9140..4e5682c8636 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/builds_spec.rb b/spec/features/projects/jobs_spec.rb index ab10434e10c..0eda46649db 100644 --- a/spec/features/projects/builds_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' require 'tempfile' -feature 'Builds', :feature do +feature 'Jobs', :feature do let(:user) { create(:user) } let(:user_access_level) { :developer } let(:project) { create(:project) } @@ -19,12 +19,12 @@ feature 'Builds', :feature do login_as(user) end - describe "GET /:project/builds" do + describe "GET /:project/jobs" do let!(:build) { create(:ci_build, pipeline: pipeline) } context "Pending scope" do before do - visit namespace_project_builds_path(project.namespace, project, scope: :pending) + visit namespace_project_jobs_path(project.namespace, project, scope: :pending) end it "shows Pending tab jobs" do @@ -39,7 +39,7 @@ feature 'Builds', :feature do context "Running scope" do before do build.run! - visit namespace_project_builds_path(project.namespace, project, scope: :running) + visit namespace_project_jobs_path(project.namespace, project, scope: :running) end it "shows Running tab jobs" do @@ -54,7 +54,7 @@ feature 'Builds', :feature do context "Finished scope" do before do build.run! - visit namespace_project_builds_path(project.namespace, project, scope: :finished) + visit namespace_project_jobs_path(project.namespace, project, scope: :finished) end it "shows Finished tab jobs" do @@ -67,7 +67,7 @@ feature 'Builds', :feature do context "All jobs" do before do project.builds.running_or_pending.each(&:success) - visit namespace_project_builds_path(project.namespace, project) + visit namespace_project_jobs_path(project.namespace, project) end it "shows All tab jobs" do @@ -78,12 +78,26 @@ feature 'Builds', :feature do expect(page).not_to have_link 'Cancel running' end end + + context "when visiting old URL" do + let(:jobs_url) do + namespace_project_jobs_path(project.namespace, project) + end + + before do + visit jobs_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(jobs_url) + end + end end - describe "POST /:project/builds/:id/cancel_all" do + describe "POST /:project/jobs/:id/cancel_all" do before do build.run! - visit namespace_project_builds_path(project.namespace, project) + visit namespace_project_jobs_path(project.namespace, project) click_link "Cancel running" end @@ -97,10 +111,10 @@ feature 'Builds', :feature do end end - describe "GET /:project/builds/:id" do + describe "GET /:project/jobs/:id" do context "Job from project" do before do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end it 'shows commit`s data' do @@ -117,7 +131,7 @@ feature 'Builds', :feature do context "Job from other project" do before do - visit namespace_project_build_path(project.namespace, project, build2) + visit namespace_project_job_path(project.namespace, project, build2) end it { expect(page.status_code).to eq(404) } @@ -126,7 +140,7 @@ feature 'Builds', :feature do context "Download artifacts" do before do build.update_attributes(artifacts_file: artifacts_file) - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end it 'has button to download artifacts' do @@ -139,7 +153,7 @@ feature 'Builds', :feature do build.update_attributes(artifacts_file: artifacts_file, artifacts_expire_at: expire_at) - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end context 'no expire date defined' do @@ -183,14 +197,29 @@ feature 'Builds', :feature do end end + context "when visiting old URL" do + let(:job_url) do + namespace_project_job_path(project.namespace, project, build) + end + + before do + visit job_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(job_url) + end + end + feature 'Raw trace' do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + + visit namespace_project_job_path(project.namespace, project, build) end it do - expect(page).to have_link 'Raw' + expect(page).to have_css('.js-raw-link') end end @@ -198,7 +227,7 @@ feature 'Builds', :feature do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end context 'when job has an initial trace' do @@ -222,7 +251,7 @@ feature 'Builds', :feature do end before do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end it 'shows variable key and value after click', js: true do @@ -247,17 +276,17 @@ feature 'Builds', :feature do let(:build) { create(:ci_build, :success, environment: environment.name, deployments: [deployment], pipeline: pipeline) } it 'shows a link for the job' do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) expect(page).to have_link environment.name end end - context 'job is complete and not successfull' do + context 'job is complete and not successful' do let(:build) { create(:ci_build, :failed, environment: environment.name, pipeline: pipeline) } it 'shows a link for the job' do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) expect(page).to have_link environment.name end @@ -268,7 +297,7 @@ feature 'Builds', :feature do let(:build) { create(:ci_build, :success, environment: environment.name, pipeline: pipeline) } it 'shows a link to latest deployment' do - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) expect(page).to have_link('latest deployment') end @@ -276,11 +305,11 @@ feature 'Builds', :feature do end end - describe "POST /:project/builds/:id/cancel" do + describe "POST /:project/jobs/:id/cancel" do context "Job from project" do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) click_link "Cancel" end @@ -294,19 +323,19 @@ feature 'Builds', :feature do context "Job from other project" do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) - page.driver.post(cancel_namespace_project_build_path(project.namespace, project, build2)) + visit namespace_project_job_path(project.namespace, project, build) + page.driver.post(cancel_namespace_project_job_path(project.namespace, project, build2)) end it { expect(page.status_code).to eq(404) } end end - describe "POST /:project/builds/:id/retry" do + describe "POST /:project/jobs/:id/retry" do context "Job from project" do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) click_link 'Cancel' page.within('.build-header') do click_link 'Retry job' @@ -322,18 +351,18 @@ feature 'Builds', :feature do end end - context "Build from other project" do + context "Job from other project" do before do build.run! - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) click_link 'Cancel' - page.driver.post(retry_namespace_project_build_path(project.namespace, project, build2)) + page.driver.post(retry_namespace_project_job_path(project.namespace, project, build2)) end it { expect(page).to have_http_status(404) } end - context "Build that current user is not allowed to retry" do + context "Job that current user is not allowed to retry" do before do build.run! build.cancel! @@ -341,7 +370,7 @@ feature 'Builds', :feature do logout_direct login_with(create(:user)) - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) end it 'does not show the Retry button' do @@ -352,31 +381,31 @@ feature 'Builds', :feature do end end - describe "GET /:project/builds/:id/download" do + describe "GET /:project/jobs/:id/download" do before do build.update_attributes(artifacts_file: artifacts_file) - visit namespace_project_build_path(project.namespace, project, build) + visit namespace_project_job_path(project.namespace, project, build) click_link 'Download' end context "Build from other project" do before do build2.update_attributes(artifacts_file: artifacts_file) - visit download_namespace_project_build_artifacts_path(project.namespace, project, build2) + visit download_namespace_project_job_artifacts_path(project.namespace, project, build2) end it { expect(page.status_code).to eq(404) } end end - describe 'GET /:project/builds/:id/raw' do + describe 'GET /:project/jobs/:id/raw', :js do context 'access source' do - context 'build from project' do + context 'job 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_build_path(project.namespace, project, build) - page.within('.js-build-sidebar') { click_link 'Raw' } + visit namespace_project_job_path(project.namespace, project, build) + find('.js-raw-link-controller').click() end it 'sends the right headers' do @@ -386,11 +415,11 @@ feature 'Builds', :feature do end end - context 'build from other project' do + context 'job 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_build_path(project.namespace, project, build2) + visit raw_namespace_project_job_path(project.namespace, project, build2) end it 'sends the right headers' do @@ -403,23 +432,23 @@ feature 'Builds', :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! allow_any_instance_of(Gitlab::Ci::Trace).to receive(:paths) .and_return(paths) - visit namespace_project_build_path(project.namespace, project, build) + 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 @@ -429,46 +458,60 @@ feature 'Builds', :feature do end end - context 'when build has trace in DB' do + context 'when job has trace in DB' 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 + + context "when visiting old URL" do + let(:raw_job_url) do + raw_namespace_project_job_path(project.namespace, project, build) + end + + before do + visit raw_job_url.sub('/-/jobs', '/builds') + end + + it "redirects to new URL" do + expect(page.current_path).to eq(raw_job_url) + end + end end - describe "GET /:project/builds/:id/trace.json" do - context "Build from project" do + describe "GET /:project/jobs/:id/trace.json" do + context "Job from project" do before do - visit trace_namespace_project_build_path(project.namespace, project, build, format: :json) + visit trace_namespace_project_job_path(project.namespace, project, build, format: :json) end it { expect(page.status_code).to eq(200) } end - context "Build from other project" do + context "Job from other project" do before do - visit trace_namespace_project_build_path(project.namespace, project, build2, format: :json) + visit trace_namespace_project_job_path(project.namespace, project, build2, format: :json) end it { expect(page.status_code).to eq(404) } end end - describe "GET /:project/builds/:id/status" do - context "Build from project" do + describe "GET /:project/jobs/:id/status" do + context "Job from project" do before do - visit status_namespace_project_build_path(project.namespace, project, build) + visit status_namespace_project_job_path(project.namespace, project, build) end it { expect(page.status_code).to eq(200) } end - context "Build from other project" do + context "Job from other project" do before do - visit status_namespace_project_build_path(project.namespace, project, build2) + visit status_namespace_project_job_path(project.namespace, project, build2) end it { expect(page.status_code).to eq(404) } diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb index b7ae5f0b925..d428f6fcf22 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 1bf8f710b9f..ec48a4bd726 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 f40e1bc4930..317949d6b56 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/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index cfac54ef259..36a3ddca6ef 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -229,7 +229,6 @@ describe 'Pipeline', :feature, :js do before { find('.js-retry-button').trigger('click') } it { expect(page).not_to have_content('Retry') } - it { expect(page).to have_selector('.retried') } end end @@ -240,7 +239,6 @@ describe 'Pipeline', :feature, :js do before { click_on 'Cancel running' } it { expect(page).not_to have_content('Cancel running') } - it { expect(page).to have_selector('.ci-canceled') } end end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index a97a92aa64f..05c2bf350f1 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -442,6 +442,8 @@ describe 'Pipelines', :feature, :js do it 'creates a new pipeline' do expect { click_on 'Create pipeline' } .to change { Ci::Pipeline.count }.by(1) + + expect(Ci::Pipeline.last).to be_web end end diff --git a/spec/features/projects/services/jira_service_spec.rb b/spec/features/projects/services/jira_service_spec.rb new file mode 100644 index 00000000000..c96d87e5708 --- /dev/null +++ b/spec/features/projects/services/jira_service_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +feature 'Setup Jira service', :feature, :js do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:service) { project.create_jira_service } + + let(:url) { 'http://jira.example.com' } + let(:project_url) { 'http://username:password@jira.example.com/rest/api/2/project/GitLabProject' } + + def fill_form(active = true) + check 'Active' if active + + fill_in 'service_url', with: url + fill_in 'service_project_key', with: 'GitLabProject' + fill_in 'service_username', with: 'username' + fill_in 'service_password', with: 'password' + fill_in 'service_jira_issue_transition_id', with: '25' + end + + before do + project.team << [user, :master] + login_as(user) + + visit namespace_project_settings_integrations_path(project.namespace, project) + end + + describe 'user sets and activates Jira Service' do + context 'when Jira connection test succeeds' do + before do + WebMock.stub_request(:get, project_url) + end + + it 'activates the JIRA service' do + click_link('JIRA') + fill_form + click_button('Test settings and save changes') + wait_for_requests + + expect(page).to have_content('JIRA activated.') + expect(current_path).to eq(namespace_project_settings_integrations_path(project.namespace, project)) + end + end + + context 'when Jira connection test fails' do + before do + WebMock.stub_request(:get, project_url).to_return(status: 401) + end + + it 'shows errors when some required fields are not filled in' do + click_link('JIRA') + + check 'Active' + fill_in 'service_password', with: 'password' + click_button('Test settings and save changes') + + page.within('.service-settings') do + expect(page).to have_content('This field is required.') + end + end + + it 'activates the JIRA service' do + click_link('JIRA') + fill_form + click_button('Test settings and save changes') + wait_for_requests + + expect(find('.flash-container-page')).to have_content 'Test failed.' + expect(find('.flash-container-page')).to have_content 'Save anyway' + + find('.flash-alert .flash-action').trigger('click') + wait_for_requests + + expect(page).to have_content('JIRA activated.') + expect(current_path).to eq(namespace_project_settings_integrations_path(project.namespace, project)) + end + end + end + + describe 'user sets Jira Service but keeps it disabled' do + context 'when Jira connection test succeeds' do + it 'activates the JIRA service' do + click_link('JIRA') + fill_form(false) + click_button('Save changes') + + expect(page).to have_content('JIRA settings saved, but not activated.') + expect(current_path).to eq(namespace_project_settings_integrations_path(project.namespace, project)) + end + end + end +end diff --git a/spec/features/projects/services/mattermost_slash_command_spec.rb b/spec/features/projects/services/mattermost_slash_command_spec.rb index dc3854262e7..1fe82222e59 100644 --- a/spec/features/projects/services/mattermost_slash_command_spec.rb +++ b/spec/features/projects/services/mattermost_slash_command_spec.rb @@ -24,15 +24,25 @@ feature 'Setup Mattermost slash commands', :feature, :js do expect(token_placeholder).to eq('XXxxXXxxXXxxXXxxXXxxXXxx') end - it 'shows the token after saving' do + it 'redirects to the integrations page after saving but not activating' do token = ('a'..'z').to_a.join fill_in 'service_token', with: token - click_on 'Save' + click_on 'Save changes' - value = find_field('service_token').value + expect(current_path).to eq(namespace_project_settings_integrations_path(project.namespace, project)) + expect(page).to have_content('Mattermost slash commands settings saved, but not activated.') + end + + it 'redirects to the integrations page after activating' do + token = ('a'..'z').to_a.join + + fill_in 'service_token', with: token + check 'service_active' + click_on 'Save changes' - expect(value).to eq(token) + expect(current_path).to eq(namespace_project_settings_integrations_path(project.namespace, project)) + expect(page).to have_content('Mattermost slash commands activated.') end it 'shows the add to mattermost button' do diff --git a/spec/features/projects/services/slack_slash_command_spec.rb b/spec/features/projects/services/slack_slash_command_spec.rb index db903a0c8f0..f53b820c460 100644 --- a/spec/features/projects/services/slack_slash_command_spec.rb +++ b/spec/features/projects/services/slack_slash_command_spec.rb @@ -21,13 +21,21 @@ feature 'Slack slash commands', feature: true do expect(page).to have_content('This service allows users to perform common') end - it 'shows the token after saving' do + it 'redirects to the integrations page after saving but not activating' do fill_in 'service_token', with: 'token' click_on 'Save' - value = find_field('service_token').value + expect(current_path).to eq(namespace_project_settings_integrations_path(project.namespace, project)) + expect(page).to have_content('Slack slash commands settings saved, but not activated.') + end + + it 'redirects to the integrations page after activating' do + fill_in 'service_token', with: 'token' + check 'service_active' + click_on 'Save' - expect(value).to eq('token') + expect(current_path).to eq(namespace_project_settings_integrations_path(project.namespace, project)) + expect(page).to have_content('Slack slash commands activated.') end it 'shows the correct trigger url' do diff --git a/spec/features/projects/settings/integration_settings_spec.rb b/spec/features/projects/settings/integration_settings_spec.rb index d3232f0cc16..fbaea14a2be 100644 --- a/spec/features/projects/settings/integration_settings_spec.rb +++ b/spec/features/projects/settings/integration_settings_spec.rb @@ -85,11 +85,55 @@ feature 'Integration settings', feature: true do expect(current_path).to eq(integrations_path) end - scenario 'remove existing webhook' do - hook - visit integrations_path + context 'remove existing webhook' do + scenario 'from webhooks list page' do + hook + visit integrations_path + + expect { click_link 'Remove' }.to change(ProjectHook, :count).by(-1) + end + + scenario 'from webhook edit page' do + hook + visit integrations_path + click_link 'Edit' + + expect { click_link 'Remove' }.to change(ProjectHook, :count).by(-1) + end + end + end + + context 'Webhook logs' do + let(:hook) { create(:project_hook, project: project) } + let(:hook_log) { create(:web_hook_log, web_hook: hook, internal_error_message: 'some error') } + + scenario 'show list of hook logs' do + hook_log + visit edit_namespace_project_hook_path(project.namespace, project, hook) + + expect(page).to have_content('Recent Deliveries') + expect(page).to have_content(hook_log.url) + end + + scenario 'show hook log details' do + hook_log + visit edit_namespace_project_hook_path(project.namespace, project, hook) + click_link 'View details' + + expect(page).to have_content("POST #{hook_log.url}") + expect(page).to have_content(hook_log.internal_error_message) + expect(page).to have_content('Resend Request') + end + + scenario 'retry hook log' do + WebMock.stub_request(:post, hook.url) + + hook_log + visit edit_namespace_project_hook_path(project.namespace, project, hook) + click_link 'View details' + click_link 'Resend Request' - expect { click_link 'Remove' }.to change(ProjectHook, :count).by(-1) + expect(current_path).to eq(edit_namespace_project_hook_path(project.namespace, project, hook)) end end end diff --git a/spec/features/projects/sub_group_issuables_spec.rb b/spec/features/projects/sub_group_issuables_spec.rb new file mode 100644 index 00000000000..e88907b8016 --- /dev/null +++ b/spec/features/projects/sub_group_issuables_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +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') } + let(:user) { create(:user) } + + before do + project.add_master(user) + login_as user + end + + it 'shows the full subgroup title when issues index page is empty' do + visit namespace_project_issues_path(project.namespace.to_param, project.to_param) + + expect_to_have_full_subgroup_title + end + + it 'shows the full subgroup title when merge requests index page is empty' do + visit namespace_project_merge_requests_path(project.namespace.to_param, project.to_param) + + expect_to_have_full_subgroup_title + end + + def expect_to_have_full_subgroup_title + title = find('.title-container') + + expect(title).not_to have_selector '.initializing' + expect(title).to have_content 'group / subgroup / project' + end +end 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 6825b95c8aa..95826e7e5be 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/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 78a76d9c112..2a2655bbdb5 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -334,7 +334,7 @@ describe "Internal Project Access", feature: true do end describe "GET /:project_path/builds" do - subject { namespace_project_builds_path(project.namespace, project) } + subject { namespace_project_jobs_path(project.namespace, project) } context "when allowed for public and internal" do before { project.update(public_builds: true) } @@ -368,7 +368,7 @@ describe "Internal Project Access", feature: true do describe "GET /:project_path/builds/:id" do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { namespace_project_build_path(project.namespace, project, build.id) } + subject { namespace_project_job_path(project.namespace, project, build.id) } context "when allowed for public and internal" do before { project.update(public_builds: true) } @@ -402,7 +402,7 @@ describe "Internal Project Access", feature: true do describe 'GET /:project_path/builds/:id/trace' do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { trace_namespace_project_build_path(project.namespace, project, build.id) } + subject { trace_namespace_project_job_path(project.namespace, project, build.id) } context 'when allowed for public and internal' do before do diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index a66f6e09055..b676c236758 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -330,7 +330,7 @@ describe "Private Project Access", feature: true do end describe "GET /:project_path/builds" do - subject { namespace_project_builds_path(project.namespace, project) } + subject { namespace_project_jobs_path(project.namespace, project) } it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:owner).of(project) } @@ -358,7 +358,7 @@ describe "Private Project Access", feature: true do describe "GET /:project_path/builds/:id" do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { namespace_project_build_path(project.namespace, project, build.id) } + subject { namespace_project_job_path(project.namespace, project, build.id) } it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:owner).of(project) } @@ -391,7 +391,7 @@ describe "Private Project Access", feature: true do describe 'GET /:project_path/builds/:id/trace' do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { trace_namespace_project_build_path(project.namespace, project, build.id) } + subject { trace_namespace_project_job_path(project.namespace, project, build.id) } it { is_expected.to be_allowed_for(:admin) } it { is_expected.to be_allowed_for(:owner).of(project) } diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index 5cd575500c3..35d5163941e 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -154,7 +154,7 @@ describe "Public Project Access", feature: true do end describe "GET /:project_path/builds" do - subject { namespace_project_builds_path(project.namespace, project) } + subject { namespace_project_jobs_path(project.namespace, project) } context "when allowed for public" do before { project.update(public_builds: true) } @@ -188,7 +188,7 @@ describe "Public Project Access", feature: true do describe "GET /:project_path/builds/:id" do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { namespace_project_build_path(project.namespace, project, build.id) } + subject { namespace_project_job_path(project.namespace, project, build.id) } context "when allowed for public" do before { project.update(public_builds: true) } @@ -222,7 +222,7 @@ describe "Public Project Access", feature: true do describe 'GET /:project_path/builds/:id/trace' do let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } - subject { trace_namespace_project_build_path(project.namespace, project, build.id) } + subject { trace_namespace_project_job_path(project.namespace, project, build.id) } context 'when allowed for public' do before do diff --git a/spec/features/variables_spec.rb b/spec/features/variables_spec.rb index b83a230c1f8..d0c982919db 100644 --- a/spec/features/variables_spec.rb +++ b/spec/features/variables_spec.rb @@ -19,7 +19,7 @@ describe 'Project variables', js: true do end end - it 'adds new variable' do + it 'adds new secret variable' do fill_in('variable_key', with: 'key') fill_in('variable_value', with: 'key value') click_button('Add new variable') @@ -27,6 +27,7 @@ describe 'Project variables', js: true do expect(page).to have_content('Variables were successfully updated.') page.within('.variables-table') do expect(page).to have_content('key') + expect(page).to have_content('No') end end @@ -41,6 +42,19 @@ describe 'Project variables', js: true do end end + it 'adds new protected variable' do + fill_in('variable_key', with: 'key') + fill_in('variable_value', with: 'value') + check('Protected') + click_button('Add new variable') + + expect(page).to have_content('Variables were successfully updated.') + page.within('.variables-table') do + expect(page).to have_content('key') + expect(page).to have_content('Yes') + end + end + it 'reveals and hides new variable' do fill_in('variable_key', with: 'key') fill_in('variable_value', with: 'key value') @@ -85,7 +99,7 @@ describe 'Project variables', js: true do click_button('Save variable') expect(page).to have_content('Variable was successfully updated.') - expect(project.variables.first.value).to eq('key value') + expect(project.variables(true).first.value).to eq('key value') end it 'edits variable with empty value' do @@ -98,6 +112,34 @@ describe 'Project variables', js: true do click_button('Save variable') expect(page).to have_content('Variable was successfully updated.') - expect(project.variables.first.value).to eq('') + expect(project.variables(true).first.value).to eq('') + end + + it 'edits variable to be protected' do + page.within('.variables-table') do + find('.btn-variable-edit').click + end + + expect(page).to have_content('Update variable') + check('Protected') + click_button('Save variable') + + expect(page).to have_content('Variable was successfully updated.') + expect(project.variables(true).first).to be_protected + end + + it 'edits variable to be unprotected' do + project.variables.first.update(protected: true) + + page.within('.variables-table') do + find('.btn-variable-edit').click + end + + expect(page).to have_content('Update variable') + uncheck('Protected') + click_button('Save variable') + + expect(page).to have_content('Variable was successfully updated.') + expect(project.variables(true).first).not_to be_protected end end diff --git a/spec/finders/group_members_finder_spec.rb b/spec/finders/group_members_finder_spec.rb index b762756f9ce..db3fcc23475 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 cf691cf684b..300ba8422e8 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/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb index 148adcffe3b..03d98459e8c 100644 --- a/spec/finders/projects_finder_spec.rb +++ b/spec/finders/projects_finder_spec.rb @@ -137,6 +137,13 @@ describe ProjectsFinder do it { is_expected.to eq([public_project]) } end + describe 'filter by owned' do + let(:params) { { owned: true } } + let!(:owned_project) { create(:empty_project, :private, namespace: current_user.namespace) } + + it { is_expected.to eq([owned_project]) } + end + describe 'filter by non_public' do let(:params) { { non_public: true } } before do @@ -146,13 +153,19 @@ describe ProjectsFinder do it { is_expected.to eq([private_project]) } end - describe 'filter by viewable_starred_projects' do + describe 'filter by starred' do let(:params) { { starred: true } } before do current_user.toggle_star(public_project) end it { is_expected.to eq([public_project]) } + + it 'returns only projects the user has access to' do + current_user.toggle_star(private_project) + + is_expected.to eq([public_project]) + end end describe 'sorting' do diff --git a/spec/fixtures/api/schemas/entities/merge_request.json b/spec/fixtures/api/schemas/entities/merge_request.json index 4afbb87453e..b6a59a6cc47 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/fixtures/api/schemas/pipeline_schedule.json b/spec/fixtures/api/schemas/pipeline_schedule.json new file mode 100644 index 00000000000..f6346bd0fb6 --- /dev/null +++ b/spec/fixtures/api/schemas/pipeline_schedule.json @@ -0,0 +1,41 @@ +{ + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "description": { "type": "string" }, + "ref": { "type": "string" }, + "cron": { "type": "string" }, + "cron_timezone": { "type": "string" }, + "next_run_at": { "type": "date" }, + "active": { "type": "boolean" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "last_pipeline": { + "type": ["object", "null"], + "properties": { + "id": { "type": "integer" }, + "sha": { "type": "string" }, + "ref": { "type": "string" }, + "status": { "type": "string" } + }, + "additionalProperties": false + }, + "owner": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + } + }, + "required": [ + "id", "description", "ref", "cron", "cron_timezone", "next_run_at", + "active", "created_at", "updated_at", "owner" + ], + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/pipeline_schedules.json b/spec/fixtures/api/schemas/pipeline_schedules.json new file mode 100644 index 00000000000..173a28d2505 --- /dev/null +++ b/spec/fixtures/api/schemas/pipeline_schedules.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "pipeline_schedule.json" } +} diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb index 6157abfe339..049475a5408 100644 --- a/spec/helpers/avatars_helper_spec.rb +++ b/spec/helpers/avatars_helper_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' describe AvatarsHelper do + include ApplicationHelper + let(:user) { create(:user) } describe '#user_avatar' do @@ -18,4 +20,103 @@ describe AvatarsHelper do is_expected.to include(CGI.escapeHTML(user.avatar_url(size: 16))) end end + + describe '#user_avatar_without_link' do + let(:options) { { user: user } } + subject { helper.user_avatar_without_link(options) } + + it 'displays user avatar' do + is_expected.to eq image_tag( + avatar_icon(user, 16), + class: 'avatar has-tooltip s16 ', + alt: "#{user.name}'s avatar", + title: user.name, + data: { container: 'body' } + ) + end + + context 'with css_class parameter' do + let(:options) { { user: user, css_class: '.cat-pics' } } + + it 'uses provided css_class' do + is_expected.to eq image_tag( + avatar_icon(user, 16), + class: "avatar has-tooltip s16 #{options[:css_class]}", + alt: "#{user.name}'s avatar", + title: user.name, + data: { container: 'body' } + ) + end + end + + context 'with lazy parameter' do + let(:options) { { user: user, lazy: true } } + + it 'uses data-src instead of src' do + is_expected.to eq image_tag( + '', + class: 'avatar has-tooltip s16 ', + alt: "#{user.name}'s avatar", + title: user.name, + data: { container: 'body', src: avatar_icon(user, 16) } + ) + end + end + + context 'with size parameter' do + let(:options) { { user: user, size: 99 } } + + it 'uses provided size' do + is_expected.to eq image_tag( + avatar_icon(user, options[:size]), + class: "avatar has-tooltip s#{options[:size]} ", + alt: "#{user.name}'s avatar", + title: user.name, + data: { container: 'body' } + ) + end + end + + context 'with url parameter' do + let(:options) { { user: user, url: '/over/the/rainbow.png' } } + + it 'uses provided url' do + is_expected.to eq image_tag( + options[:url], + class: 'avatar has-tooltip s16 ', + alt: "#{user.name}'s avatar", + title: user.name, + data: { container: 'body' } + ) + end + end + + context 'with user_name parameter' do + let(:options) { { user_name: 'Tinky Winky', user_email: 'no@f.un' } } + + context 'with user parameter' do + let(:options) { { user: user, user_name: 'Tinky Winky' } } + + it 'prefers user parameter' do + is_expected.to eq image_tag( + avatar_icon(user, 16), + class: 'avatar has-tooltip s16 ', + alt: "#{user.name}'s avatar", + title: user.name, + data: { container: 'body' } + ) + end + end + + it 'uses user_name and user_email parameter if user is not present' do + is_expected.to eq image_tag( + avatar_icon(options[:user_email], 16), + class: 'avatar has-tooltip s16 ', + alt: "#{options[:user_name]}'s avatar", + title: options[:user_name], + data: { container: 'body' } + ) + end + end + end end diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index 41b5df12522..bd3a3d24b84 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -118,8 +118,8 @@ describe BlobHelper do Class.new(BlobViewer::Base) do include BlobViewer::ServerSide - self.overridable_max_size = 1.megabyte - self.max_size = 5.megabytes + self.collapse_limit = 1.megabyte + self.size_limit = 5.megabytes self.type = :rich end end @@ -129,7 +129,7 @@ describe BlobHelper do describe '#blob_render_error_reason' do context 'for error :too_large' do - context 'when the blob size is larger than the absolute max size' do + context 'when the blob size is larger than the absolute size limit' do let(:blob) { fake_blob(size: 10.megabytes) } it 'returns an error message' do @@ -137,7 +137,7 @@ describe BlobHelper do end end - context 'when the blob size is larger than the max size' do + context 'when the blob size is larger than the size limit' do let(:blob) { fake_blob(size: 2.megabytes) } it 'returns an error message' do @@ -168,21 +168,19 @@ describe BlobHelper do controller.params[:id] = File.join('master', blob.path) end - context 'for error :too_large' do - context 'when the max size can be overridden' do - let(:blob) { fake_blob(size: 2.megabytes) } + context 'for error :collapsed' do + let(:blob) { fake_blob(size: 2.megabytes) } - it 'includes a "load it anyway" link' do - expect(helper.blob_render_error_options(viewer)).to include(/load it anyway/) - end + it 'includes a "load it anyway" link' do + expect(helper.blob_render_error_options(viewer)).to include(/load it anyway/) end + end - context 'when the max size cannot be overridden' do - let(:blob) { fake_blob(size: 10.megabytes) } + context 'for error :too_large' do + let(:blob) { fake_blob(size: 10.megabytes) } - it 'does not include a "load it anyway" link' do - expect(helper.blob_render_error_options(viewer)).not_to include(/load it anyway/) - end + it 'does not include a "load it anyway" link' do + expect(helper.blob_render_error_options(viewer)).not_to include(/load it anyway/) end context 'when the viewer is rich' do diff --git a/spec/helpers/diff_helper_spec.rb b/spec/helpers/diff_helper_spec.rb index dd6566d25bb..a74615e07f9 100644 --- a/spec/helpers/diff_helper_spec.rb +++ b/spec/helpers/diff_helper_spec.rb @@ -33,17 +33,17 @@ describe DiffHelper do describe 'diff_options' do it 'returns no collapse false' do - expect(diff_options).to include(no_collapse: false) + expect(diff_options).to include(expanded: false) end - it 'returns no collapse true if expand_all_diffs' do - allow(controller).to receive(:params) { { expand_all_diffs: true } } - expect(diff_options).to include(no_collapse: true) + it 'returns no collapse true if expanded' do + allow(controller).to receive(:params) { { expanded: true } } + expect(diff_options).to include(expanded: true) end it 'returns no collapse true if action name diff_for_path' do allow(controller).to receive(:action_name) { 'diff_for_path' } - expect(diff_options).to include(no_collapse: true) + expect(diff_options).to include(expanded: true) end it 'returns paths if action name diff_for_path and param old path' do @@ -129,6 +129,33 @@ describe DiffHelper do end end + describe '#parallel_diff_discussions' do + let(:discussion) { { 'abc_3_3' => 'comment' } } + let(:diff_file) { double(line_code: 'abc_3_3') } + + before do + helper.instance_variable_set(:@grouped_diff_discussions, discussion) + end + + it 'does not put comments on nonewline lines' do + left = Gitlab::Diff::Line.new('\\nonewline', 'old-nonewline', 3, 3, 3) + right = Gitlab::Diff::Line.new('\\nonewline', 'new-nonewline', 3, 3, 3) + + result = helper.parallel_diff_discussions(left, right, diff_file) + + expect(result).to eq([nil, nil]) + end + + it 'puts comments on added lines' do + left = Gitlab::Diff::Line.new('\\nonewline', 'old-nonewline', 3, 3, 3) + right = Gitlab::Diff::Line.new('new line', 'add', 3, 3, 3) + + result = helper.parallel_diff_discussions(left, right, diff_file) + + expect(result).to eq([nil, 'comment']) + end + end + describe "#diff_match_line" do let(:old_pos) { 40 } let(:new_pos) { 50 } diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index c1ecb46aece..8fcf7f5fa15 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -192,4 +192,22 @@ describe IssuablesHelper do expect(helper.issuable_filter_present?).to be_falsey end end + + describe '#updated_at_by' do + let(:user) { create(:user) } + let(:unedited_issuable) { create(:issue) } + let(:edited_issuable) { create(:issue, last_edited_by: user, created_at: 3.days.ago, updated_at: 2.days.ago, last_edited_at: 2.days.ago) } + let(:edited_updated_at_by) do + { + updatedAt: edited_issuable.updated_at.to_time.iso8601, + updatedBy: { + name: user.name, + path: user_path(user) + } + } + end + + it { expect(helper.updated_at_by(unedited_issuable)).to eq({}) } + it { expect(helper.updated_at_by(edited_issuable)).to eq(edited_updated_at_by) } + end end diff --git a/spec/helpers/submodule_helper_spec.rb b/spec/helpers/submodule_helper_spec.rb index 18935be95c9..b05ae5c2232 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 8ec96bdb583..461908f3fde 100644 --- a/spec/javascripts/build_spec.js +++ b/spec/javascripts/build_spec.js @@ -8,13 +8,12 @@ import '~/breakpoints'; import 'vendor/jquery.nicescroll'; describe('Build', () => { - const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/builds/1`; + const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/-/jobs/1`; preloadFixtures('builds/build-with-artifacts.html.raw'); 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/droplab/plugins/ajax_filter_spec.js b/spec/javascripts/droplab/plugins/ajax_filter_spec.js new file mode 100644 index 00000000000..8155d98b543 --- /dev/null +++ b/spec/javascripts/droplab/plugins/ajax_filter_spec.js @@ -0,0 +1,72 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; +import AjaxFilter from '~/droplab/plugins/ajax_filter'; + +describe('AjaxFilter', () => { + let dummyConfig; + const dummyData = 'dummy data'; + let dummyList; + + beforeEach(() => { + dummyConfig = { + endpoint: 'dummy endpoint', + searchKey: 'dummy search key', + }; + dummyList = { + data: [], + list: document.createElement('div'), + }; + + AjaxFilter.hook = { + config: { + AjaxFilter: dummyConfig, + }, + list: dummyList, + }; + }); + + describe('trigger', () => { + let ajaxSpy; + + beforeEach(() => { + spyOn(AjaxCache, 'retrieve').and.callFake(url => ajaxSpy(url)); + spyOn(AjaxFilter, '_loadData'); + + dummyConfig.onLoadingFinished = jasmine.createSpy('spy'); + + const dynamicList = document.createElement('div'); + dynamicList.dataset.dynamic = true; + dummyList.list.appendChild(dynamicList); + }); + + it('calls onLoadingFinished after loading data', (done) => { + ajaxSpy = (url) => { + expect(url).toBe('dummy endpoint?dummy search key='); + return Promise.resolve(dummyData); + }; + + AjaxFilter.trigger() + .then(() => { + expect(dummyConfig.onLoadingFinished.calls.count()).toBe(1); + }) + .then(done) + .catch(done.fail); + }); + + it('does not call onLoadingFinished if Ajax call fails', (done) => { + const dummyError = new Error('My dummy is sick! :-('); + ajaxSpy = (url) => { + expect(url).toBe('dummy endpoint?dummy search key='); + return Promise.reject(dummyError); + }; + + AjaxFilter.trigger() + .then(done.fail) + .catch((error) => { + expect(error).toBe(dummyError); + expect(dummyConfig.onLoadingFinished.calls.count()).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/environments/environment_spec.js b/spec/javascripts/environments/environment_spec.js index 1c54cc3054c..c31642ac788 100644 --- a/spec/javascripts/environments/environment_spec.js +++ b/spec/javascripts/environments/environment_spec.js @@ -41,7 +41,7 @@ describe('Environment', () => { setTimeout(() => { expect( component.$el.querySelector('.js-new-environment-button').textContent, - ).toContain('New Environment'); + ).toContain('New environment'); expect( component.$el.querySelector('.js-blank-state-title').textContent, diff --git a/spec/javascripts/environments/environments_store_spec.js b/spec/javascripts/environments/environments_store_spec.js index f617c4bdffe..6e855530b21 100644 --- a/spec/javascripts/environments/environments_store_spec.js +++ b/spec/javascripts/environments/environments_store_spec.js @@ -123,4 +123,13 @@ describe('Store', () => { expect(store.state.paginationInformation).toEqual(expectedResult); }); }); + + describe('getOpenFolders', () => { + it('should return open folder', () => { + store.storeEnvironments(serverData); + + store.toggleFolder(store.state.environments[1]); + expect(store.getOpenFolders()[0]).toEqual(store.state.environments[1]); + }); + }); }); diff --git a/spec/javascripts/filtered_search/dropdown_utils_spec.js b/spec/javascripts/filtered_search/dropdown_utils_spec.js index bb02abdeea2..f55726379f3 100644 --- a/spec/javascripts/filtered_search/dropdown_utils_spec.js +++ b/spec/javascripts/filtered_search/dropdown_utils_spec.js @@ -2,8 +2,12 @@ import '~/extensions/array'; import '~/filtered_search/dropdown_utils'; import '~/filtered_search/filtered_search_tokenizer'; import '~/filtered_search/filtered_search_dropdown_manager'; +import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; describe('Dropdown Utils', () => { + const issueListFixture = 'issues/issue_list.html.raw'; + preloadFixtures(issueListFixture); + describe('getEscapedText', () => { it('should return same word when it has no space', () => { const escaped = gl.DropdownUtils.getEscapedText('textWithoutSpace'); @@ -314,4 +318,29 @@ describe('Dropdown Utils', () => { }); }); }); + + describe('getSearchQuery', () => { + let authorToken; + + beforeEach(() => { + loadFixtures(issueListFixture); + + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user'); + const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term'); + + const tokensContainer = document.querySelector('.tokens-container'); + tokensContainer.appendChild(searchTermToken); + tokensContainer.appendChild(authorToken); + }); + + it('uses original value if present', () => { + const originalValue = 'original dance'; + const valueContainer = authorToken.querySelector('.value-container'); + valueContainer.dataset.originalValue = originalValue; + + const searchQuery = gl.DropdownUtils.getSearchQuery(); + + expect(searchQuery).toBe(' search term author:original dance'); + }); + }); }); diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index 8688332782d..6e59ee96c6b 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/filtered_search/filtered_search_visual_tokens_spec.js b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js index c5fa2b17106..fa4343ffbc8 100644 --- a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js @@ -1,10 +1,22 @@ import AjaxCache from '~/lib/utils/ajax_cache'; +import UsersCache from '~/lib/utils/users_cache'; import '~/filtered_search/filtered_search_visual_tokens'; import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; describe('Filtered Search Visual Tokens', () => { + const subject = gl.FilteredSearchVisualTokens; + + const findElements = (tokenElement) => { + const tokenNameElement = tokenElement.querySelector('.name'); + const tokenValueContainer = tokenElement.querySelector('.value-container'); + const tokenValueElement = tokenValueContainer.querySelector('.value'); + return { tokenNameElement, tokenValueContainer, tokenValueElement }; + }; + let tokensContainer; + let authorToken; + let bugLabelToken; beforeEach(() => { setFixtures(` @@ -13,12 +25,15 @@ describe('Filtered Search Visual Tokens', () => { </ul> `); tokensContainer = document.querySelector('.tokens-container'); + + authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user'); + bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug'); }); describe('getLastVisualTokenBeforeInput', () => { it('returns when there are no visual tokens', () => { const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + = subject.getLastVisualTokenBeforeInput(); expect(lastVisualToken).toEqual(null); expect(isLastVisualTokenValid).toEqual(true); @@ -27,11 +42,11 @@ describe('Filtered Search Visual Tokens', () => { describe('input is the last item in tokensContainer', () => { it('returns when there is one visual token', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'), + bugLabelToken.outerHTML, ); const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + = subject.getLastVisualTokenBeforeInput(); expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); expect(isLastVisualTokenValid).toEqual(true); @@ -43,7 +58,7 @@ describe('Filtered Search Visual Tokens', () => { ); const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + = subject.getLastVisualTokenBeforeInput(); expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); expect(isLastVisualTokenValid).toEqual(false); @@ -51,13 +66,13 @@ describe('Filtered Search Visual Tokens', () => { it('returns when there are multiple visual tokens', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${bugLabelToken.outerHTML} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} `); const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + = subject.getLastVisualTokenBeforeInput(); const items = document.querySelectorAll('.tokens-container .js-visual-token'); expect(lastVisualToken.isEqualNode(items[items.length - 1])).toEqual(true); @@ -66,13 +81,13 @@ describe('Filtered Search Visual Tokens', () => { it('returns when there are multiple visual tokens and an incomplete visual token', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${bugLabelToken.outerHTML} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('assignee')} `); const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + = subject.getLastVisualTokenBeforeInput(); const items = document.querySelectorAll('.tokens-container .js-visual-token'); expect(lastVisualToken.isEqualNode(items[items.length - 1])).toEqual(true); @@ -83,13 +98,13 @@ describe('Filtered Search Visual Tokens', () => { describe('input is a middle item in tokensContainer', () => { it('returns last token before input', () => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${bugLabelToken.outerHTML} ${FilteredSearchSpecHelper.createInputHTML()} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} `); const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + = subject.getLastVisualTokenBeforeInput(); expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); expect(isLastVisualTokenValid).toEqual(true); @@ -103,7 +118,7 @@ describe('Filtered Search Visual Tokens', () => { `); const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + = subject.getLastVisualTokenBeforeInput(); expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); expect(isLastVisualTokenValid).toEqual(false); @@ -114,7 +129,7 @@ describe('Filtered Search Visual Tokens', () => { describe('unselectTokens', () => { it('does nothing when there are no tokens', () => { const beforeHTML = tokensContainer.innerHTML; - gl.FilteredSearchVisualTokens.unselectTokens(); + subject.unselectTokens(); expect(tokensContainer.innerHTML).toEqual(beforeHTML); }); @@ -128,7 +143,7 @@ describe('Filtered Search Visual Tokens', () => { const selected = tokensContainer.querySelector('.js-visual-token .selected'); expect(selected.classList.contains('selected')).toEqual(true); - gl.FilteredSearchVisualTokens.unselectTokens(); + subject.unselectTokens(); expect(selected.classList.contains('selected')).toEqual(false); }); @@ -137,7 +152,7 @@ describe('Filtered Search Visual Tokens', () => { describe('selectToken', () => { beforeEach(() => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${bugLabelToken.outerHTML} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')} `); @@ -147,7 +162,7 @@ describe('Filtered Search Visual Tokens', () => { const firstTokenButton = tokensContainer.querySelector('.js-visual-token .selectable'); firstTokenButton.classList.add('selected'); - gl.FilteredSearchVisualTokens.selectToken(firstTokenButton); + subject.selectToken(firstTokenButton); expect(firstTokenButton.classList.contains('selected')).toEqual(false); }); @@ -156,7 +171,7 @@ describe('Filtered Search Visual Tokens', () => { it('adds selected class', () => { const firstTokenButton = tokensContainer.querySelector('.js-visual-token .selectable'); - gl.FilteredSearchVisualTokens.selectToken(firstTokenButton); + subject.selectToken(firstTokenButton); expect(firstTokenButton.classList.contains('selected')).toEqual(true); }); @@ -165,7 +180,7 @@ describe('Filtered Search Visual Tokens', () => { const tokenButtons = tokensContainer.querySelectorAll('.js-visual-token .selectable'); tokenButtons[1].classList.add('selected'); - gl.FilteredSearchVisualTokens.selectToken(tokenButtons[0]); + subject.selectToken(tokenButtons[0]); expect(tokenButtons[0].classList.contains('selected')).toEqual(true); expect(tokenButtons[1].classList.contains('selected')).toEqual(false); @@ -181,7 +196,7 @@ describe('Filtered Search Visual Tokens', () => { expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); - gl.FilteredSearchVisualTokens.removeSelectedToken(); + subject.removeSelectedToken(); expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); }); @@ -193,7 +208,7 @@ describe('Filtered Search Visual Tokens', () => { expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); - gl.FilteredSearchVisualTokens.removeSelectedToken(); + subject.removeSelectedToken(); expect(tokensContainer.querySelector('.js-visual-token .selectable')).toEqual(null); }); @@ -205,7 +220,7 @@ describe('Filtered Search Visual Tokens', () => { beforeEach(() => { setFixtures(` <div class="test-area"> - ${gl.FilteredSearchVisualTokens.createVisualTokenElementHTML()} + ${subject.createVisualTokenElementHTML()} </div> `); @@ -245,7 +260,7 @@ describe('Filtered Search Visual Tokens', () => { describe('addVisualTokenElement', () => { it('renders search visual tokens', () => { - gl.FilteredSearchVisualTokens.addVisualTokenElement('search term', null, true); + subject.addVisualTokenElement('search term', null, true); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-term')).toEqual(true); @@ -254,7 +269,7 @@ describe('Filtered Search Visual Tokens', () => { }); it('renders filter visual token name', () => { - gl.FilteredSearchVisualTokens.addVisualTokenElement('milestone'); + subject.addVisualTokenElement('milestone'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); @@ -263,7 +278,7 @@ describe('Filtered Search Visual Tokens', () => { }); it('renders filter visual token name and value', () => { - gl.FilteredSearchVisualTokens.addVisualTokenElement('label', 'Frontend'); + subject.addVisualTokenElement('label', 'Frontend'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); @@ -274,7 +289,7 @@ describe('Filtered Search Visual Tokens', () => { it('inserts visual token before input', () => { tokensContainer.appendChild(FilteredSearchSpecHelper.createFilterVisualToken('assignee', '@root')); - gl.FilteredSearchVisualTokens.addVisualTokenElement('label', 'Frontend'); + subject.addVisualTokenElement('label', 'Frontend'); const tokens = tokensContainer.querySelectorAll('.js-visual-token'); const labelToken = tokens[0]; const assigneeToken = tokens[1]; @@ -296,7 +311,7 @@ describe('Filtered Search Visual Tokens', () => { ); const original = tokensContainer.innerHTML; - gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value'); + subject.addValueToPreviousVisualTokenElement('value'); expect(original).toEqual(tokensContainer.innerHTML); }); @@ -308,7 +323,7 @@ describe('Filtered Search Visual Tokens', () => { `); const original = tokensContainer.innerHTML; - gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value'); + subject.addValueToPreviousVisualTokenElement('value'); expect(original).toEqual(tokensContainer.innerHTML); }); @@ -319,7 +334,7 @@ describe('Filtered Search Visual Tokens', () => { ); const original = tokensContainer.innerHTML; - gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value'); + subject.addValueToPreviousVisualTokenElement('value'); const updatedToken = tokensContainer.querySelector('.js-visual-token'); expect(updatedToken.querySelector('.name').innerText).toEqual('label'); @@ -330,7 +345,7 @@ describe('Filtered Search Visual Tokens', () => { describe('addFilterVisualToken', () => { it('creates visual token with just tokenName', () => { - gl.FilteredSearchVisualTokens.addFilterVisualToken('milestone'); + subject.addFilterVisualToken('milestone'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); @@ -339,8 +354,8 @@ describe('Filtered Search Visual Tokens', () => { }); it('creates visual token with just tokenValue', () => { - gl.FilteredSearchVisualTokens.addFilterVisualToken('milestone'); - gl.FilteredSearchVisualTokens.addFilterVisualToken('%8.17'); + subject.addFilterVisualToken('milestone'); + subject.addFilterVisualToken('%8.17'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); @@ -349,7 +364,7 @@ describe('Filtered Search Visual Tokens', () => { }); it('creates full visual token', () => { - gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', '@john'); + subject.addFilterVisualToken('assignee', '@john'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-token')).toEqual(true); @@ -360,7 +375,7 @@ describe('Filtered Search Visual Tokens', () => { describe('addSearchVisualToken', () => { it('creates search visual token', () => { - gl.FilteredSearchVisualTokens.addSearchVisualToken('search term'); + subject.addSearchVisualToken('search term'); const token = tokensContainer.querySelector('.js-visual-token'); expect(token.classList.contains('filtered-search-term')).toEqual(true); @@ -374,7 +389,7 @@ describe('Filtered Search Visual Tokens', () => { ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} `); - gl.FilteredSearchVisualTokens.addSearchVisualToken('append this'); + subject.addSearchVisualToken('append this'); const token = tokensContainer.querySelector('.filtered-search-term'); expect(token.querySelector('.name').innerText).toEqual('search term append this'); @@ -386,10 +401,26 @@ describe('Filtered Search Visual Tokens', () => { it('should get last token value', () => { const value = '~bug'; tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( - FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', value), + bugLabelToken.outerHTML, + ); + + expect(subject.getLastTokenPartial()).toEqual(value); + }); + + it('should get last token original value if available', () => { + const originalValue = '@user'; + const valueContainer = authorToken.querySelector('.value-container'); + valueContainer.dataset.originalValue = originalValue; + const avatar = document.createElement('img'); + const valueElement = valueContainer.querySelector('.value'); + valueElement.insertAdjacentElement('afterbegin', avatar); + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + authorToken.outerHTML, ); - expect(gl.FilteredSearchVisualTokens.getLastTokenPartial()).toEqual(value); + const lastTokenValue = subject.getLastTokenPartial(); + + expect(lastTokenValue).toEqual(originalValue); }); it('should get last token name if there is no value', () => { @@ -398,11 +429,11 @@ describe('Filtered Search Visual Tokens', () => { FilteredSearchSpecHelper.createNameFilterVisualTokenHTML(name), ); - expect(gl.FilteredSearchVisualTokens.getLastTokenPartial()).toEqual(name); + expect(subject.getLastTokenPartial()).toEqual(name); }); it('should return empty when there are no tokens', () => { - expect(gl.FilteredSearchVisualTokens.getLastTokenPartial()).toEqual(''); + expect(subject.getLastTokenPartial()).toEqual(''); }); }); @@ -414,7 +445,7 @@ describe('Filtered Search Visual Tokens', () => { expect(tokensContainer.querySelector('.js-visual-token .value')).not.toEqual(null); - gl.FilteredSearchVisualTokens.removeLastTokenPartial(); + subject.removeLastTokenPartial(); expect(tokensContainer.querySelector('.js-visual-token .value')).toEqual(null); }); @@ -426,14 +457,14 @@ describe('Filtered Search Visual Tokens', () => { expect(tokensContainer.querySelector('.js-visual-token .name')).not.toEqual(null); - gl.FilteredSearchVisualTokens.removeLastTokenPartial(); + subject.removeLastTokenPartial(); expect(tokensContainer.querySelector('.js-visual-token .name')).toEqual(null); }); it('should not remove anything when there are no tokens', () => { const html = tokensContainer.innerHTML; - gl.FilteredSearchVisualTokens.removeLastTokenPartial(); + subject.removeLastTokenPartial(); expect(tokensContainer.innerHTML).toEqual(html); }); @@ -442,7 +473,7 @@ describe('Filtered Search Visual Tokens', () => { describe('tokenizeInput', () => { it('does not do anything if there is no input', () => { const original = tokensContainer.innerHTML; - gl.FilteredSearchVisualTokens.tokenizeInput(); + subject.tokenizeInput(); expect(tokensContainer.innerHTML).toEqual(original); }); @@ -454,7 +485,7 @@ describe('Filtered Search Visual Tokens', () => { const input = document.querySelector('.filtered-search'); input.value = 'some value'; - gl.FilteredSearchVisualTokens.tokenizeInput(); + subject.tokenizeInput(); const newToken = tokensContainer.querySelector('.filtered-search-term'); @@ -470,7 +501,7 @@ describe('Filtered Search Visual Tokens', () => { const input = document.querySelector('.filtered-search'); input.value = '@john'; - gl.FilteredSearchVisualTokens.tokenizeInput(); + subject.tokenizeInput(); const updatedToken = tokensContainer.querySelector('.filtered-search-token'); @@ -497,29 +528,39 @@ describe('Filtered Search Visual Tokens', () => { it('tokenize\'s existing input', () => { input.value = 'some text'; - spyOn(gl.FilteredSearchVisualTokens, 'tokenizeInput').and.callThrough(); + spyOn(subject, 'tokenizeInput').and.callThrough(); - gl.FilteredSearchVisualTokens.editToken(token); + subject.editToken(token); - expect(gl.FilteredSearchVisualTokens.tokenizeInput).toHaveBeenCalled(); + expect(subject.tokenizeInput).toHaveBeenCalled(); expect(input.value).not.toEqual('some text'); }); it('moves input to the token position', () => { expect(tokensContainer.children[3].querySelector('.filtered-search')).not.toEqual(null); - gl.FilteredSearchVisualTokens.editToken(token); + subject.editToken(token); expect(tokensContainer.children[1].querySelector('.filtered-search')).not.toEqual(null); expect(tokensContainer.children[3].querySelector('.filtered-search')).toEqual(null); }); it('input contains the visual token value', () => { - gl.FilteredSearchVisualTokens.editToken(token); + subject.editToken(token); expect(input.value).toEqual('none'); }); + it('input contains the original value if present', () => { + const originalValue = '@user'; + const valueContainer = token.querySelector('.value-container'); + valueContainer.dataset.originalValue = originalValue; + + subject.editToken(token); + + expect(input.value).toEqual(originalValue); + }); + describe('selected token is a search term token', () => { beforeEach(() => { token = document.querySelector('.filtered-search-term'); @@ -528,7 +569,7 @@ describe('Filtered Search Visual Tokens', () => { it('token is removed', () => { expect(tokensContainer.querySelector('.filtered-search-term')).not.toEqual(null); - gl.FilteredSearchVisualTokens.editToken(token); + subject.editToken(token); expect(tokensContainer.querySelector('.filtered-search-term')).toEqual(null); }); @@ -536,7 +577,7 @@ describe('Filtered Search Visual Tokens', () => { it('input has the same value as removed token', () => { expect(input.value).toEqual(''); - gl.FilteredSearchVisualTokens.editToken(token); + subject.editToken(token); expect(input.value).toEqual('search'); }); @@ -549,25 +590,25 @@ describe('Filtered Search Visual Tokens', () => { FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none'), ); - spyOn(gl.FilteredSearchVisualTokens, 'tokenizeInput').and.callFake(() => {}); - spyOn(gl.FilteredSearchVisualTokens, 'getLastVisualTokenBeforeInput').and.callThrough(); + spyOn(subject, 'tokenizeInput').and.callFake(() => {}); + spyOn(subject, 'getLastVisualTokenBeforeInput').and.callThrough(); - gl.FilteredSearchVisualTokens.moveInputToTheRight(); + subject.moveInputToTheRight(); - expect(gl.FilteredSearchVisualTokens.tokenizeInput).toHaveBeenCalled(); - expect(gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput).not.toHaveBeenCalled(); + expect(subject.tokenizeInput).toHaveBeenCalled(); + expect(subject.getLastVisualTokenBeforeInput).not.toHaveBeenCalled(); }); it('tokenize\'s input', () => { tokensContainer.innerHTML = ` ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} ${FilteredSearchSpecHelper.createInputHTML()} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${bugLabelToken.outerHTML} `; document.querySelector('.filtered-search').value = 'none'; - gl.FilteredSearchVisualTokens.moveInputToTheRight(); + subject.moveInputToTheRight(); const value = tokensContainer.querySelector('.js-visual-token .value'); expect(value.innerText).toEqual('none'); @@ -577,12 +618,12 @@ describe('Filtered Search Visual Tokens', () => { tokensContainer.innerHTML = ` ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} ${FilteredSearchSpecHelper.createInputHTML()} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${bugLabelToken.outerHTML} `; document.querySelector('.filtered-search').value = 'test'; - gl.FilteredSearchVisualTokens.moveInputToTheRight(); + subject.moveInputToTheRight(); const searchValue = tokensContainer.querySelector('.filtered-search-term .name'); expect(searchValue.innerText).toEqual('test'); @@ -592,10 +633,10 @@ describe('Filtered Search Visual Tokens', () => { tokensContainer.innerHTML = ` ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} ${FilteredSearchSpecHelper.createInputHTML()} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${bugLabelToken.outerHTML} `; - gl.FilteredSearchVisualTokens.moveInputToTheRight(); + subject.moveInputToTheRight(); expect(tokensContainer.children[2].querySelector('.filtered-search')).not.toEqual(null); }); @@ -607,7 +648,7 @@ describe('Filtered Search Visual Tokens', () => { ${FilteredSearchSpecHelper.createInputHTML('', '~bug')} `; - gl.FilteredSearchVisualTokens.moveInputToTheRight(); + subject.moveInputToTheRight(); const token = tokensContainer.children[1]; expect(token.querySelector('.value').innerText).toEqual('~bug'); @@ -615,42 +656,144 @@ describe('Filtered Search Visual Tokens', () => { }); describe('renderVisualTokenValue', () => { - let searchTokens; + const keywordToken = FilteredSearchSpecHelper.createFilterVisualToken('search'); + const milestoneToken = FilteredSearchSpecHelper.createFilterVisualToken('milestone', 'upcoming'); + + let updateLabelTokenColorSpy; + let updateUserTokenAppearanceSpy; beforeEach(() => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} - ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search')} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'upcoming')} + ${authorToken.outerHTML} + ${bugLabelToken.outerHTML} + ${keywordToken.outerHTML} + ${milestoneToken.outerHTML} `); - searchTokens = document.querySelectorAll('.filtered-search-token'); + spyOn(subject, 'updateLabelTokenColor'); + updateLabelTokenColorSpy = subject.updateLabelTokenColor; + + spyOn(subject, 'updateUserTokenAppearance'); + updateUserTokenAppearanceSpy = subject.updateUserTokenAppearance; }); - it('renders a token value element', () => { - spyOn(gl.FilteredSearchVisualTokens, 'updateLabelTokenColor'); - const updateLabelTokenColorSpy = gl.FilteredSearchVisualTokens.updateLabelTokenColor; + it('renders a author token value element', () => { + const { tokenNameElement, tokenValueContainer, tokenValueElement } = + findElements(authorToken); + const tokenName = tokenNameElement.innerText; + const tokenValue = 'new value'; - expect(searchTokens.length).toBe(2); - Array.prototype.forEach.call(searchTokens, (token) => { - updateLabelTokenColorSpy.calls.reset(); + subject.renderVisualTokenValue(authorToken, tokenName, tokenValue); - const tokenName = token.querySelector('.name').innerText; - const tokenValue = 'new value'; - gl.FilteredSearchVisualTokens.renderVisualTokenValue(token, tokenName, tokenValue); + expect(tokenValueElement.innerText).toBe(tokenValue); + expect(updateUserTokenAppearanceSpy.calls.count()).toBe(1); + const expectedArgs = [tokenValueContainer, tokenValueElement, tokenValue]; + expect(updateUserTokenAppearanceSpy.calls.argsFor(0)).toEqual(expectedArgs); + expect(updateLabelTokenColorSpy.calls.count()).toBe(0); + }); - const tokenValueElement = token.querySelector('.value'); - expect(tokenValueElement.innerText).toBe(tokenValue); + it('renders a label token value element', () => { + const { tokenNameElement, tokenValueContainer, tokenValueElement } = + findElements(bugLabelToken); + const tokenName = tokenNameElement.innerText; + const tokenValue = 'new value'; - if (tokenName.toLowerCase() === 'label') { - const tokenValueContainer = token.querySelector('.value-container'); - expect(updateLabelTokenColorSpy.calls.count()).toBe(1); - const expectedArgs = [tokenValueContainer, tokenValue]; - expect(updateLabelTokenColorSpy.calls.argsFor(0)).toEqual(expectedArgs); - } else { - expect(updateLabelTokenColorSpy.calls.count()).toBe(0); - } - }); + subject.renderVisualTokenValue(bugLabelToken, tokenName, tokenValue); + + expect(tokenValueElement.innerText).toBe(tokenValue); + expect(updateLabelTokenColorSpy.calls.count()).toBe(1); + const expectedArgs = [tokenValueContainer, tokenValue]; + expect(updateLabelTokenColorSpy.calls.argsFor(0)).toEqual(expectedArgs); + expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0); + }); + + it('renders a milestone token value element', () => { + const { tokenNameElement, tokenValueElement } = findElements(milestoneToken); + const tokenName = tokenNameElement.innerText; + const tokenValue = 'new value'; + + subject.renderVisualTokenValue(milestoneToken, tokenName, tokenValue); + + expect(tokenValueElement.innerText).toBe(tokenValue); + expect(updateLabelTokenColorSpy.calls.count()).toBe(0); + expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0); + }); + }); + + describe('updateUserTokenAppearance', () => { + let usersCacheSpy; + + beforeEach(() => { + spyOn(UsersCache, 'retrieve').and.callFake(username => usersCacheSpy(username)); + }); + + it('ignores special value "none"', (done) => { + usersCacheSpy = (username) => { + expect(username).toBe('none'); + done.fail('Should not resolve "none"!'); + }; + const { tokenValueContainer, tokenValueElement } = findElements(authorToken); + + subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, 'none') + .then(done) + .catch(done.fail); + }); + + it('ignores error if UsersCache throws', (done) => { + spyOn(window, 'Flash'); + const dummyError = new Error('Earth rotated backwards'); + const { tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = (username) => { + expect(`@${username}`).toBe(tokenValue); + return Promise.reject(dummyError); + }; + + subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(window.Flash.calls.count()).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + it('does nothing if user cannot be found', (done) => { + const { tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = (username) => { + expect(`@${username}`).toBe(tokenValue); + return Promise.resolve(undefined); + }; + + subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(tokenValueElement.innerText).toBe(tokenValue); + }) + .then(done) + .catch(done.fail); + }); + + it('replaces author token with avatar and display name', (done) => { + const dummyUser = { + name: 'Important Person', + avatar_url: 'https://host.invalid/mypics/avatar.png', + }; + const { tokenValueContainer, tokenValueElement } = findElements(authorToken); + const tokenValue = tokenValueElement.innerText; + usersCacheSpy = (username) => { + expect(`@${username}`).toBe(tokenValue); + return Promise.resolve(dummyUser); + }; + + subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) + .then(() => { + expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue); + expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); + const avatar = tokenValueElement.querySelector('img.avatar'); + expect(avatar.src).toBe(dummyUser.avatar_url); + }) + .then(done) + .catch(done.fail); }); }); @@ -659,21 +802,16 @@ describe('Filtered Search Visual Tokens', () => { const dummyEndpoint = '/dummy/endpoint'; preloadFixtures(jsonFixtureName); - const labelData = getJSONFixture(jsonFixtureName); - const findLabel = tokenValue => labelData.find( - label => tokenValue === `~${gl.DropdownUtils.getEscapedText(label.title)}`, - ); - const bugLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~bug'); + let labelData; + + beforeAll(() => { + labelData = getJSONFixture(jsonFixtureName); + }); + const missingLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~doesnotexist'); const spaceLabelToken = FilteredSearchSpecHelper.createFilterVisualToken('label', '~"some space"'); - const parseColor = (color) => { - const dummyElement = document.createElement('div'); - dummyElement.style.color = color; - return dummyElement.style.color; - }; - beforeEach(() => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${bugLabelToken.outerHTML} @@ -688,28 +826,60 @@ describe('Filtered Search Visual Tokens', () => { AjaxCache.internalStorage[`${dummyEndpoint}/labels.json`] = labelData; }); - const testCase = (token, done) => { - const tokenValueContainer = token.querySelector('.value-container'); - const tokenValue = token.querySelector('.value').innerText; - const label = findLabel(tokenValue); + const parseColor = (color) => { + const dummyElement = document.createElement('div'); + dummyElement.style.color = color; + return dummyElement.style.color; + }; - gl.FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue) - .then(() => { - if (label) { - expect(tokenValueContainer.getAttribute('style')).not.toBe(null); - expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color)); - expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color)); - } else { - expect(token).toBe(missingLabelToken); - expect(tokenValueContainer.getAttribute('style')).toBe(null); - } - }) - .then(done) - .catch(fail); + const expectValueContainerStyle = (tokenValueContainer, label) => { + expect(tokenValueContainer.getAttribute('style')).not.toBe(null); + expect(tokenValueContainer.style.backgroundColor).toBe(parseColor(label.color)); + expect(tokenValueContainer.style.color).toBe(parseColor(label.text_color)); }; - it('updates the color of a label token', done => testCase(bugLabelToken, done)); - it('updates the color of a label token with spaces', done => testCase(spaceLabelToken, done)); - it('does not change color of a missing label', done => testCase(missingLabelToken, done)); + const findLabel = tokenValue => labelData.find( + label => tokenValue === `~${gl.DropdownUtils.getEscapedText(label.title)}`, + ); + + it('updates the color of a label token', (done) => { + const { tokenValueContainer, tokenValueElement } = findElements(bugLabelToken); + const tokenValue = tokenValueElement.innerText; + const matchingLabel = findLabel(tokenValue); + + subject.updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + expectValueContainerStyle(tokenValueContainer, matchingLabel); + }) + .then(done) + .catch(done.fail); + }); + + it('updates the color of a label token with spaces', (done) => { + const { tokenValueContainer, tokenValueElement } = findElements(spaceLabelToken); + const tokenValue = tokenValueElement.innerText; + const matchingLabel = findLabel(tokenValue); + + subject.updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + expectValueContainerStyle(tokenValueContainer, matchingLabel); + }) + .then(done) + .catch(done.fail); + }); + + it('does not change color of a missing label', (done) => { + const { tokenValueContainer, tokenValueElement } = findElements(missingLabelToken); + const tokenValue = tokenValueElement.innerText; + const matchingLabel = findLabel(tokenValue); + expect(matchingLabel).toBe(undefined); + + subject.updateLabelTokenColor(tokenValueContainer, tokenValue) + .then(() => { + expect(tokenValueContainer.getAttribute('style')).toBe(null); + }) + .then(done) + .catch(done.fail); + }); }); }); diff --git a/spec/javascripts/fixtures/issues.rb b/spec/javascripts/fixtures/issues.rb index 88e3f860809..1a30909977e 100644 --- a/spec/javascripts/fixtures/issues.rb +++ b/spec/javascripts/fixtures/issues.rb @@ -36,6 +36,17 @@ describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller render_issue(example.description, issue) end + it 'issues/issue_list.html.raw' do |example| + create(:issue, project: project) + + get :index, + namespace_id: project.namespace.to_param, + project_id: project + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end + private def render_issue(fixture_file_name, issue) diff --git a/spec/javascripts/fixtures/builds.rb b/spec/javascripts/fixtures/jobs.rb index 320de791b08..dc7dde1138c 100644 --- a/spec/javascripts/fixtures/builds.rb +++ b/spec/javascripts/fixtures/jobs.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Projects::BuildsController, '(JavaScript fixtures)', type: :controller do +describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do include JavaScriptFixturesHelpers let(:admin) { create(:admin) } diff --git a/spec/javascripts/fixtures/raw.rb b/spec/javascripts/fixtures/raw.rb index 1ce622fc836..17533443d76 100644 --- a/spec/javascripts/fixtures/raw.rb +++ b/spec/javascripts/fixtures/raw.rb @@ -21,4 +21,10 @@ describe 'Raw files', '(JavaScript fixtures)', type: :controller do store_frontend_fixture(blob.data, example.description) end + + it 'blob/notebook/math.json' do |example| + blob = project.repository.blob_at('93ee732', 'files/ipython/math.ipynb') + + store_frontend_fixture(blob.data, example.description) + end end diff --git a/spec/javascripts/fixtures/services.rb b/spec/javascripts/fixtures/services.rb new file mode 100644 index 00000000000..554451d1bbf --- /dev/null +++ b/spec/javascripts/fixtures/services.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe Projects::ServicesController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project_empty_repo, namespace: namespace, path: 'services-project') } + let!(:service) { create(:custom_issue_tracker_service, project: project, title: 'Custom Issue Tracker') } + + + render_views + + before(:all) do + clean_frontend_fixtures('services/') + end + + before(:each) do + sign_in(admin) + end + + it 'services/edit_service.html.raw' do |example| + get :edit, + namespace_id: namespace, + project_id: project, + id: service.to_param + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/javascripts/helpers/filtered_search_spec_helper.js b/spec/javascripts/helpers/filtered_search_spec_helper.js index 0d7092a2357..8933dd5def4 100644 --- a/spec/javascripts/helpers/filtered_search_spec_helper.js +++ b/spec/javascripts/helpers/filtered_search_spec_helper.js @@ -30,12 +30,15 @@ export default class FilteredSearchSpecHelper { `; } + static createSearchVisualToken(name) { + const li = document.createElement('li'); + li.classList.add('js-visual-token', 'filtered-search-term'); + li.innerHTML = `<div class="name">${name}</div>`; + return li; + } + static createSearchVisualTokenHTML(name) { - return ` - <li class="js-visual-token filtered-search-term"> - <div class="name">${name}</div> - </li> - `; + return FilteredSearchSpecHelper.createSearchVisualToken(name).outerHTML; } static createInputHTML(placeholder = '', value = '') { diff --git a/spec/javascripts/integrations/integration_settings_form_spec.js b/spec/javascripts/integrations/integration_settings_form_spec.js new file mode 100644 index 00000000000..45909d4e70e --- /dev/null +++ b/spec/javascripts/integrations/integration_settings_form_spec.js @@ -0,0 +1,199 @@ +import IntegrationSettingsForm from '~/integrations/integration_settings_form'; + +describe('IntegrationSettingsForm', () => { + const FIXTURE = 'services/edit_service.html.raw'; + preloadFixtures(FIXTURE); + + beforeEach(() => { + loadFixtures(FIXTURE); + }); + + describe('contructor', () => { + let integrationSettingsForm; + + beforeEach(() => { + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + spyOn(integrationSettingsForm, 'init'); + }); + + it('should initialize form element refs on class object', () => { + // Form Reference + expect(integrationSettingsForm.$form).toBeDefined(); + expect(integrationSettingsForm.$form.prop('nodeName')).toEqual('FORM'); + + // Form Child Elements + expect(integrationSettingsForm.$serviceToggle).toBeDefined(); + expect(integrationSettingsForm.$submitBtn).toBeDefined(); + expect(integrationSettingsForm.$submitBtnLoader).toBeDefined(); + expect(integrationSettingsForm.$submitBtnLabel).toBeDefined(); + }); + + it('should initialize form metadata on class object', () => { + expect(integrationSettingsForm.testEndPoint).toBeDefined(); + expect(integrationSettingsForm.canTestService).toBeDefined(); + }); + }); + + describe('toggleServiceState', () => { + let integrationSettingsForm; + + beforeEach(() => { + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + }); + + it('should remove `novalidate` attribute to form when called with `true`', () => { + integrationSettingsForm.toggleServiceState(true); + + expect(integrationSettingsForm.$form.attr('novalidate')).not.toBeDefined(); + }); + + it('should set `novalidate` attribute to form when called with `false`', () => { + integrationSettingsForm.toggleServiceState(false); + + expect(integrationSettingsForm.$form.attr('novalidate')).toBeDefined(); + }); + }); + + describe('toggleSubmitBtnLabel', () => { + let integrationSettingsForm; + + beforeEach(() => { + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + }); + + it('should set Save button label to "Test settings and save changes" when serviceActive & canTestService are `true`', () => { + integrationSettingsForm.canTestService = true; + + integrationSettingsForm.toggleSubmitBtnLabel(true); + expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Test settings and save changes'); + }); + + it('should set Save button label to "Save changes" when either serviceActive or canTestService (or both) is `false`', () => { + integrationSettingsForm.canTestService = false; + + integrationSettingsForm.toggleSubmitBtnLabel(false); + expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes'); + + integrationSettingsForm.toggleSubmitBtnLabel(true); + expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes'); + + integrationSettingsForm.canTestService = true; + + integrationSettingsForm.toggleSubmitBtnLabel(false); + expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes'); + }); + }); + + describe('toggleSubmitBtnState', () => { + let integrationSettingsForm; + + beforeEach(() => { + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + }); + + it('should disable Save button and show loader animation when called with `true`', () => { + integrationSettingsForm.toggleSubmitBtnState(true); + + expect(integrationSettingsForm.$submitBtn.is(':disabled')).toBeTruthy(); + expect(integrationSettingsForm.$submitBtnLoader.hasClass('hidden')).toBeFalsy(); + }); + + it('should enable Save button and hide loader animation when called with `false`', () => { + integrationSettingsForm.toggleSubmitBtnState(false); + + expect(integrationSettingsForm.$submitBtn.is(':disabled')).toBeFalsy(); + expect(integrationSettingsForm.$submitBtnLoader.hasClass('hidden')).toBeTruthy(); + }); + }); + + describe('testSettings', () => { + let integrationSettingsForm; + let formData; + + beforeEach(() => { + integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + formData = integrationSettingsForm.$form.serialize(); + }); + + it('should make an ajax request with provided `formData`', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + + integrationSettingsForm.testSettings(formData); + + expect($.ajax).toHaveBeenCalledWith({ + type: 'PUT', + url: integrationSettingsForm.testEndPoint, + data: formData, + }); + }); + + it('should show error Flash with `Save anyway` action if ajax request responds with error in test', () => { + const errorMessage = 'Test failed.'; + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + + integrationSettingsForm.testSettings(formData); + + deferred.resolve({ error: true, message: errorMessage }); + + const $flashContainer = $('.flash-container'); + expect($flashContainer.find('.flash-text').text()).toEqual(errorMessage); + expect($flashContainer.find('.flash-action')).toBeDefined(); + expect($flashContainer.find('.flash-action').text()).toEqual('Save anyway'); + }); + + it('should submit form if ajax request responds without any error in test', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + + integrationSettingsForm.testSettings(formData); + + spyOn(integrationSettingsForm.$form, 'submit'); + deferred.resolve({ error: false }); + + expect(integrationSettingsForm.$form.submit).toHaveBeenCalled(); + }); + + it('should submit form when clicked on `Save anyway` action of error Flash', () => { + const errorMessage = 'Test failed.'; + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + + integrationSettingsForm.testSettings(formData); + + deferred.resolve({ error: true, message: errorMessage }); + + const $flashAction = $('.flash-container .flash-action'); + expect($flashAction).toBeDefined(); + + spyOn(integrationSettingsForm.$form, 'submit'); + $flashAction.trigger('click'); + expect(integrationSettingsForm.$form.submit).toHaveBeenCalled(); + }); + + it('should show error Flash if ajax request failed', () => { + const errorMessage = 'Something went wrong on our end.'; + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + + integrationSettingsForm.testSettings(formData); + + deferred.reject(); + + expect($('.flash-container .flash-text').text()).toEqual(errorMessage); + }); + + it('should always call `toggleSubmitBtnState` with `false` once request is completed', () => { + const deferred = $.Deferred(); + spyOn($, 'ajax').and.returnValue(deferred.promise()); + + integrationSettingsForm.testSettings(formData); + + spyOn(integrationSettingsForm, 'toggleSubmitBtnState'); + deferred.reject(); + + expect(integrationSettingsForm.toggleSubmitBtnState).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index ee456869c53..59c006aa0af 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import '~/render_math'; import '~/render_gfm'; import issuableApp from '~/issue_show/components/app.vue'; +import eventHub from '~/issue_show/event_hub'; import issueShowData from '../mock_data'; const issueShowInterceptor = data => (request, next) => { @@ -13,6 +14,10 @@ const issueShowInterceptor = data => (request, next) => { })); }; +function formatText(text) { + return text.trim().replace(/\s\s+/g, ' '); +} + describe('Issuable output', () => { document.body.innerHTML = '<span id="task_status"></span>'; @@ -22,14 +27,25 @@ describe('Issuable output', () => { const IssuableDescriptionComponent = Vue.extend(issuableApp); Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); + spyOn(eventHub, '$emit'); + vm = new IssuableDescriptionComponent({ propsData: { canUpdate: true, + canDestroy: true, + canMove: true, endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes', issuableRef: '#1', - initialTitle: '', + initialTitleHtml: '', + initialTitleText: '', initialDescriptionHtml: '', initialDescriptionText: '', + markdownPreviewUrl: '/', + markdownDocs: '/', + projectsAutocompleteUrl: '/', + isConfidential: false, + projectNamespace: '/', + projectPath: '/', }, }).$mount(); }); @@ -38,12 +54,17 @@ describe('Issuable output', () => { Vue.http.interceptors = _.without(Vue.http.interceptors, issueShowInterceptor); }); - it('should render a title/description and update title/description on update', (done) => { + it('should render a title/description/edited and update title/description/edited on update', (done) => { setTimeout(() => { + const editedText = vm.$el.querySelector('.edited-text'); + expect(document.querySelector('title').innerText).toContain('this is a title (#1)'); expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>this is a title</p>'); expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>this is a description!</p>'); expect(vm.$el.querySelector('.js-task-list-field').value).toContain('this is a description'); + expect(formatText(editedText.innerText)).toMatch(/Edited[\s\S]+?by Some User/); + expect(editedText.querySelector('.author_link').href).toMatch(/\/some_user$/); + expect(editedText.querySelector('time')).toBeTruthy(); Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest)); @@ -52,9 +73,305 @@ describe('Issuable output', () => { expect(vm.$el.querySelector('.title').innerHTML).toContain('<p>2</p>'); expect(vm.$el.querySelector('.wiki').innerHTML).toContain('<p>42</p>'); expect(vm.$el.querySelector('.js-task-list-field').value).toContain('42'); + expect(vm.$el.querySelector('.edited-text')).toBeTruthy(); + expect(formatText(vm.$el.querySelector('.edited-text').innerText)).toMatch(/Edited[\s\S]+?by Other User/); + expect(editedText.querySelector('.author_link').href).toMatch(/\/other_user$/); + expect(editedText.querySelector('time')).toBeTruthy(); + + done(); + }); + }); + }); + + it('shows actions if permissions are correct', (done) => { + vm.showForm = true; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn'), + ).not.toBeNull(); + + done(); + }); + }); + + it('does not show actions if permissions are incorrect', (done) => { + vm.showForm = true; + vm.canUpdate = false; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn'), + ).toBeNull(); + + done(); + }); + }); + + it('does not update formState if form is already open', (done) => { + vm.openForm(); + + vm.state.titleText = 'testing 123'; + + vm.openForm(); + + Vue.nextTick(() => { + expect( + vm.store.formState.title, + ).not.toBe('testing 123'); + + done(); + }); + }); + + describe('updateIssuable', () => { + it('fetches new data after update', (done) => { + spyOn(vm.service, 'getData'); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + confidential: false, + web_url: location.pathname, + }; + }, + }); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + vm.service.getData, + ).toHaveBeenCalled(); + + done(); + }); + }); + + it('reloads the page if the confidential status has changed', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + confidential: true, + web_url: location.pathname, + }; + }, + }); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + gl.utils.visitUrl, + ).toHaveBeenCalledWith(location.pathname); + + done(); + }); + }); + + it('correctly updates issuable data', (done) => { + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve(); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + vm.service.updateIssuable, + ).toHaveBeenCalledWith(vm.formState); + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + + done(); + }); + }); + + it('does not redirect if issue has not moved', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + web_url: location.pathname, + confidential: vm.isConfidential, + }; + }, + }); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + gl.utils.visitUrl, + ).not.toHaveBeenCalled(); done(); }); }); + + it('redirects if issue is moved', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + web_url: '/testing-issue-move', + confidential: vm.isConfidential, + }; + }, + }); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + gl.utils.visitUrl, + ).toHaveBeenCalledWith('/testing-issue-move'); + + done(); + }); + }); + + it('does not update issuable if project move confirm is false', (done) => { + spyOn(window, 'confirm').and.returnValue(false); + spyOn(vm.service, 'updateIssuable'); + + vm.store.formState.move_to_project_id = 1; + + vm.updateIssuable(); + + setTimeout(() => { + expect( + vm.service.updateIssuable, + ).not.toHaveBeenCalled(); + + done(); + }); + }); + + it('closes form on error', (done) => { + spyOn(window, 'Flash').and.callThrough(); + spyOn(vm.service, 'updateIssuable').and.callFake(() => new Promise((resolve, reject) => { + reject(); + })); + + vm.updateIssuable(); + + setTimeout(() => { + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + expect( + window.Flash, + ).toHaveBeenCalledWith('Error updating issue'); + + done(); + }); + }); + }); + + describe('deleteIssuable', () => { + it('changes URL when deleted', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { web_url: '/test' }; + }, + }); + })); + + vm.deleteIssuable(); + + setTimeout(() => { + expect( + gl.utils.visitUrl, + ).toHaveBeenCalledWith('/test'); + + done(); + }); + }); + + it('stops polling when deleting', (done) => { + spyOn(gl.utils, 'visitUrl'); + spyOn(vm.poll, 'stop'); + spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { web_url: '/test' }; + }, + }); + })); + + vm.deleteIssuable(); + + setTimeout(() => { + expect( + vm.poll.stop, + ).toHaveBeenCalledWith(); + + done(); + }); + }); + + it('closes form on error', (done) => { + spyOn(window, 'Flash').and.callThrough(); + spyOn(vm.service, 'deleteIssuable').and.callFake(() => new Promise((resolve, reject) => { + reject(); + })); + + vm.deleteIssuable(); + + setTimeout(() => { + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + expect( + window.Flash, + ).toHaveBeenCalledWith('Error deleting issue'); + + done(); + }); + }); + }); + + describe('open form', () => { + it('shows locked warning if form is open & data is different', (done) => { + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.initialRequest)); + + Vue.nextTick() + .then(() => new Promise((resolve) => { + setTimeout(resolve); + })) + .then(() => { + vm.openForm(); + + Vue.http.interceptors.push(issueShowInterceptor(issueShowData.secondRequest)); + + return new Promise((resolve) => { + setTimeout(resolve); + }); + }) + .then(() => { + expect( + vm.formState.lockedWarningVisible, + ).toBeTruthy(); + + expect( + vm.$el.querySelector('.alert'), + ).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); }); }); diff --git a/spec/javascripts/issue_show/components/edit_actions_spec.js b/spec/javascripts/issue_show/components/edit_actions_spec.js new file mode 100644 index 00000000000..f6625b748b6 --- /dev/null +++ b/spec/javascripts/issue_show/components/edit_actions_spec.js @@ -0,0 +1,147 @@ +import Vue from 'vue'; +import editActions from '~/issue_show/components/edit_actions.vue'; +import eventHub from '~/issue_show/event_hub'; +import Store from '~/issue_show/stores'; + +describe('Edit Actions components', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(editActions); + const store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + store.formState.title = 'test'; + + spyOn(eventHub, '$emit'); + + vm = new Component({ + propsData: { + canDestroy: true, + formState: store.formState, + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders all buttons as enabled', () => { + expect( + vm.$el.querySelectorAll('.disabled').length, + ).toBe(0); + + expect( + vm.$el.querySelectorAll('[disabled]').length, + ).toBe(0); + }); + + it('does not render delete button if canUpdate is false', (done) => { + vm.canDestroy = false; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-danger'), + ).toBeNull(); + + done(); + }); + }); + + it('disables submit button when title is blank', (done) => { + vm.formState.title = ''; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-save').getAttribute('disabled'), + ).toBe('disabled'); + + done(); + }); + }); + + describe('updateIssuable', () => { + it('sends update.issauble event when clicking save button', () => { + vm.$el.querySelector('.btn-save').click(); + + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('update.issuable'); + }); + + it('shows loading icon after clicking save button', (done) => { + vm.$el.querySelector('.btn-save').click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-save .fa'), + ).not.toBeNull(); + + done(); + }); + }); + + it('disabled button after clicking save button', (done) => { + vm.$el.querySelector('.btn-save').click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-save').getAttribute('disabled'), + ).toBe('disabled'); + + done(); + }); + }); + }); + + describe('closeForm', () => { + it('emits close.form when clicking cancel', () => { + vm.$el.querySelector('.btn-default').click(); + + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('close.form'); + }); + }); + + describe('deleteIssuable', () => { + it('sends delete.issuable event when clicking save button', () => { + spyOn(window, 'confirm').and.returnValue(true); + vm.$el.querySelector('.btn-danger').click(); + + expect( + eventHub.$emit, + ).toHaveBeenCalledWith('delete.issuable'); + }); + + it('shows loading icon after clicking delete button', (done) => { + spyOn(window, 'confirm').and.returnValue(true); + vm.$el.querySelector('.btn-danger').click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.btn-danger .fa'), + ).not.toBeNull(); + + done(); + }); + }); + + it('does no actions when confirm is false', (done) => { + spyOn(window, 'confirm').and.returnValue(false); + vm.$el.querySelector('.btn-danger').click(); + + Vue.nextTick(() => { + expect( + eventHub.$emit, + ).not.toHaveBeenCalledWith('delete.issuable'); + expect( + vm.$el.querySelector('.btn-danger .fa'), + ).toBeNull(); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/issue_show/components/edited_spec.js b/spec/javascripts/issue_show/components/edited_spec.js new file mode 100644 index 00000000000..a0d0750ae34 --- /dev/null +++ b/spec/javascripts/issue_show/components/edited_spec.js @@ -0,0 +1,49 @@ +import Vue from 'vue'; +import edited from '~/issue_show/components/edited.vue'; + +function formatText(text) { + return text.trim().replace(/\s\s+/g, ' '); +} + +describe('edited', () => { + const EditedComponent = Vue.extend(edited); + + it('should render an edited at+by string', () => { + const editedComponent = new EditedComponent({ + propsData: { + updatedAt: '2017-05-15T12:31:04.428Z', + updatedByName: 'Some User', + updatedByPath: '/some_user', + }, + }).$mount(); + + expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited[\s\S]+?by Some User/); + expect(editedComponent.$el.querySelector('.author_link').href).toMatch(/\/some_user$/); + expect(editedComponent.$el.querySelector('time')).toBeTruthy(); + }); + + it('if no updatedAt is provided, no time element will be rendered', () => { + const editedComponent = new EditedComponent({ + propsData: { + updatedByName: 'Some User', + updatedByPath: '/some_user', + }, + }).$mount(); + + expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited by Some User/); + expect(editedComponent.$el.querySelector('.author_link').href).toMatch(/\/some_user$/); + expect(editedComponent.$el.querySelector('time')).toBeFalsy(); + }); + + it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => { + const editedComponent = new EditedComponent({ + propsData: { + updatedAt: '2017-05-15T12:31:04.428Z', + }, + }).$mount(); + + expect(formatText(editedComponent.$el.innerText)).not.toMatch(/by Some User/); + expect(editedComponent.$el.querySelector('.author_link')).toBeFalsy(); + expect(editedComponent.$el.querySelector('time')).toBeTruthy(); + }); +}); diff --git a/spec/javascripts/issue_show/components/fields/description_spec.js b/spec/javascripts/issue_show/components/fields/description_spec.js new file mode 100644 index 00000000000..f5b35b1e8b0 --- /dev/null +++ b/spec/javascripts/issue_show/components/fields/description_spec.js @@ -0,0 +1,56 @@ +import Vue from 'vue'; +import Store from '~/issue_show/stores'; +import descriptionField from '~/issue_show/components/fields/description.vue'; + +describe('Description field component', () => { + let vm; + let store; + + beforeEach((done) => { + const Component = Vue.extend(descriptionField); + const el = document.createElement('div'); + store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + store.formState.description = 'test'; + + document.body.appendChild(el); + + vm = new Component({ + el, + propsData: { + markdownPreviewUrl: '/', + markdownDocs: '/', + formState: store.formState, + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders markdown field with description', () => { + expect( + vm.$el.querySelector('.md-area textarea').value, + ).toBe('test'); + }); + + it('renders markdown field with a markdown description', (done) => { + store.formState.description = '**test**'; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.md-area textarea').value, + ).toBe('**test**'); + + done(); + }); + }); + + it('focuses field when mounted', () => { + expect( + document.activeElement, + ).toBe(vm.$refs.textarea); + }); +}); diff --git a/spec/javascripts/issue_show/components/fields/description_template_spec.js b/spec/javascripts/issue_show/components/fields/description_template_spec.js new file mode 100644 index 00000000000..2b7ee65094b --- /dev/null +++ b/spec/javascripts/issue_show/components/fields/description_template_spec.js @@ -0,0 +1,49 @@ +import Vue from 'vue'; +import descriptionTemplate from '~/issue_show/components/fields/description_template.vue'; +import '~/templates/issuable_template_selector'; +import '~/templates/issuable_template_selectors'; + +describe('Issue description template component', () => { + let vm; + let formState; + + beforeEach((done) => { + const Component = Vue.extend(descriptionTemplate); + formState = { + description: 'test', + }; + + vm = new Component({ + propsData: { + formState, + issuableTemplates: [{ name: 'test' }], + projectPath: '/', + projectNamespace: '/', + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders templates as JSON array in data attribute', () => { + expect( + vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data'), + ).toBe('[{"name":"test"}]'); + }); + + it('updates formState when changing template', () => { + vm.issuableTemplate.editor.setValue('test new template'); + + expect( + formState.description, + ).toBe('test new template'); + }); + + it('returns formState description with editor getValue', () => { + formState.description = 'testing new template'; + + expect( + vm.issuableTemplate.editor.getValue(), + ).toBe('testing new template'); + }); +}); diff --git a/spec/javascripts/issue_show/components/fields/project_move_spec.js b/spec/javascripts/issue_show/components/fields/project_move_spec.js new file mode 100644 index 00000000000..86d35c33ff4 --- /dev/null +++ b/spec/javascripts/issue_show/components/fields/project_move_spec.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import projectMove from '~/issue_show/components/fields/project_move.vue'; + +describe('Project move field component', () => { + let vm; + let formState; + + beforeEach((done) => { + const Component = Vue.extend(projectMove); + + formState = { + move_to_project_id: 0, + }; + + vm = new Component({ + propsData: { + formState, + projectsAutocompleteUrl: '/autocomplete', + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('mounts select2 element', () => { + expect( + vm.$el.querySelector('.select2-container'), + ).not.toBeNull(); + }); + + it('updates formState on change', () => { + $(vm.$refs['move-dropdown']).val(2).trigger('change'); + + expect( + formState.move_to_project_id, + ).toBe(2); + }); +}); diff --git a/spec/javascripts/issue_show/components/fields/title_spec.js b/spec/javascripts/issue_show/components/fields/title_spec.js new file mode 100644 index 00000000000..53ae038a6a2 --- /dev/null +++ b/spec/javascripts/issue_show/components/fields/title_spec.js @@ -0,0 +1,30 @@ +import Vue from 'vue'; +import Store from '~/issue_show/stores'; +import titleField from '~/issue_show/components/fields/title.vue'; + +describe('Title field component', () => { + let vm; + let store; + + beforeEach(() => { + const Component = Vue.extend(titleField); + store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); + store.formState.title = 'test'; + + vm = new Component({ + propsData: { + formState: store.formState, + }, + }).$mount(); + }); + + it('renders form control with formState title', () => { + expect( + vm.$el.querySelector('.form-control').value, + ).toBe('test'); + }); +}); diff --git a/spec/javascripts/issue_show/components/form_spec.js b/spec/javascripts/issue_show/components/form_spec.js new file mode 100644 index 00000000000..9a85223208c --- /dev/null +++ b/spec/javascripts/issue_show/components/form_spec.js @@ -0,0 +1,68 @@ +import Vue from 'vue'; +import formComponent from '~/issue_show/components/form.vue'; +import '~/templates/issuable_template_selector'; +import '~/templates/issuable_template_selectors'; + +describe('Inline edit form component', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(formComponent); + + vm = new Component({ + propsData: { + canDestroy: true, + canMove: true, + formState: { + title: 'b', + description: 'a', + lockedWarningVisible: false, + }, + markdownPreviewUrl: '/', + markdownDocs: '/', + projectsAutocompleteUrl: '/', + projectPath: '/', + projectNamespace: '/', + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('does not render template selector if no templates exist', () => { + expect( + vm.$el.querySelector('.js-issuable-selector-wrap'), + ).toBeNull(); + }); + + it('renders template selector when templates exists', (done) => { + spyOn(gl, 'IssuableTemplateSelectors'); + vm.issuableTemplates = ['test']; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.js-issuable-selector-wrap'), + ).not.toBeNull(); + + done(); + }); + }); + + it('hides locked warning by default', () => { + expect( + vm.$el.querySelector('.alert'), + ).toBeNull(); + }); + + it('shows locked warning if formState is different', (done) => { + vm.formState.lockedWarningVisible = true; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.alert'), + ).not.toBeNull(); + + done(); + }); + }); +}); diff --git a/spec/javascripts/issue_show/components/title_spec.js b/spec/javascripts/issue_show/components/title_spec.js index 2f953e7e92e..a2d90a9b9f5 100644 --- a/spec/javascripts/issue_show/components/title_spec.js +++ b/spec/javascripts/issue_show/components/title_spec.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import Store from '~/issue_show/stores'; import titleComponent from '~/issue_show/components/title.vue'; describe('Title component', () => { @@ -6,11 +7,18 @@ describe('Title component', () => { beforeEach(() => { const Component = Vue.extend(titleComponent); + const store = new Store({ + titleHtml: '', + descriptionHtml: '', + issuableRef: '', + }); vm = new Component({ propsData: { issuableRef: '#1', titleHtml: 'Testing <img />', titleText: 'Testing', + showForm: false, + formState: store.formState, }, }).$mount(); }); diff --git a/spec/javascripts/issue_show/mock_data.js b/spec/javascripts/issue_show/mock_data.js index 6683d581bc5..eb3111412a7 100644 --- a/spec/javascripts/issue_show/mock_data.js +++ b/spec/javascripts/issue_show/mock_data.js @@ -5,7 +5,9 @@ export default { description: '<p>this is a description!</p>', description_text: 'this is a description', task_status: '2 of 4 completed', - updated_at: new Date().toString(), + updated_at: '2015-05-15T12:31:04.428Z', + updated_by_name: 'Some User', + updated_by_path: '/some_user', }, secondRequest: { title: '<p>2</p>', @@ -13,7 +15,9 @@ export default { description: '<p>42</p>', description_text: '42', task_status: '0 of 0 completed', - updated_at: new Date().toString(), + updated_at: '2016-05-15T12:31:04.428Z', + updated_by_name: 'Other User', + updated_by_path: '/other_user', }, issueSpecRequest: { title: '<p>this is a title</p>', @@ -21,6 +25,8 @@ export default { description: '<li class="task-list-item enabled"><input type="checkbox" class="task-list-item-checkbox">Task List Item</li>', description_text: '- [ ] Task List Item', task_status: '0 of 1 completed', - updated_at: new Date().toString(), + updated_at: '2017-05-15T12:31:04.428Z', + updated_by_name: 'Last User', + updated_by_path: '/last_user', }, }; diff --git a/spec/javascripts/lib/utils/ajax_cache_spec.js b/spec/javascripts/lib/utils/ajax_cache_spec.js index e1747a82329..2c946802dcd 100644 --- a/spec/javascripts/lib/utils/ajax_cache_spec.js +++ b/spec/javascripts/lib/utils/ajax_cache_spec.js @@ -154,5 +154,36 @@ describe('AjaxCache', () => { .then(done) .catch(fail); }); + + it('makes Ajax call even if matching data exists when forceRequest parameter is provided', (done) => { + const oldDummyResponse = { + important: 'old dummy data', + }; + + AjaxCache.internalStorage[dummyEndpoint] = oldDummyResponse; + + ajaxSpy = (url) => { + expect(url).toBe(dummyEndpoint); + const deferred = $.Deferred(); + deferred.resolve(dummyResponse); + return deferred.promise(); + }; + + // Call without forceRetrieve param + AjaxCache.retrieve(dummyEndpoint) + .then((data) => { + expect(data).toBe(oldDummyResponse); + }) + .then(done) + .catch(fail); + + // Call with forceRetrieve param + AjaxCache.retrieve(dummyEndpoint, true) + .then((data) => { + expect(data).toBe(dummyResponse); + }) + .then(done) + .catch(fail); + }); }); }); diff --git a/spec/javascripts/lib/utils/common_utils_spec.js b/spec/javascripts/lib/utils/common_utils_spec.js index e9bffd74d90..e3938a77680 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js +++ b/spec/javascripts/lib/utils/common_utils_spec.js @@ -356,7 +356,7 @@ import '~/lib/utils/common_utils'; describe('gl.utils.setCiStatusFavicon', () => { it('should set page favicon to CI status favicon based on provided status', () => { - const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/builds/1/status.json`; + const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/-/jobs/1/status.json`; const FAVICON_PATH = '//icon_status_success'; const spySetFavicon = spyOn(gl.utils, 'setFavicon').and.stub(); const spyResetFavicon = spyOn(gl.utils, 'resetFavicon').and.stub(); diff --git a/spec/javascripts/lib/utils/number_utility_spec.js b/spec/javascripts/lib/utils/number_utility_spec.js index 90b12c9f115..83c92deccdc 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_spec.js b/spec/javascripts/merge_request_spec.js index 1173fa40947..f444bcaf847 100644 --- a/spec/javascripts/merge_request_spec.js +++ b/spec/javascripts/merge_request_spec.js @@ -13,7 +13,9 @@ import '~/merge_request'; }); it('modifies the Markdown field', function() { spyOn(jQuery, 'ajax').and.stub(); - $('input[type=checkbox]').attr('checked', true).trigger('change'); + const changeEvent = document.createEvent('HTMLEvents'); + changeEvent.initEvent('change', true, true); + $('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent); return expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); }); return it('submits an ajax request on tasklist:changed', function() { diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js index 3d1706aab68..7b910282cc8 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/notebook/cells/markdown_spec.js b/spec/javascripts/notebook/cells/markdown_spec.js index 38c976f38d8..a88e9ed3d99 100644 --- a/spec/javascripts/notebook/cells/markdown_spec.js +++ b/spec/javascripts/notebook/cells/markdown_spec.js @@ -1,8 +1,11 @@ import Vue from 'vue'; import MarkdownComponent from '~/notebook/cells/markdown.vue'; +import katex from 'vendor/katex'; const Component = Vue.extend(MarkdownComponent); +window.katex = katex; + describe('Markdown component', () => { let vm; let cell; @@ -38,4 +41,58 @@ describe('Markdown component', () => { it('renders the markdown HTML', () => { expect(vm.$el.querySelector('.markdown h1')).not.toBeNull(); }); + + describe('katex', () => { + beforeEach(() => { + json = getJSONFixture('blob/notebook/math.json'); + }); + + it('renders multi-line katex', (done) => { + vm = new Component({ + propsData: { + cell: json.cells[0], + }, + }).$mount(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.katex'), + ).not.toBeNull(); + + done(); + }); + }); + + it('renders inline katex', (done) => { + vm = new Component({ + propsData: { + cell: json.cells[1], + }, + }).$mount(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('p:first-child .katex'), + ).not.toBeNull(); + + done(); + }); + }); + + it('renders multiple inline katex', (done) => { + vm = new Component({ + propsData: { + cell: json.cells[1], + }, + }).$mount(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelectorAll('p:nth-child(2) .katex').length, + ).toBe(4); + + done(); + }); + }); + }); }); diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index 025f08ee332..24335614e09 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -13,6 +13,23 @@ import '~/notes'; window.gl = window.gl || {}; gl.utils = gl.utils || {}; + const htmlEscape = (comment) => { + const escapedString = comment.replace(/["&'<>]/g, (a) => { + const escapedToken = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '`': '`' + }[a]; + + return escapedToken; + }); + + return escapedString; + }; + describe('Notes', function() { const FLASH_TYPE_ALERT = 'alert'; var commentsTemplate = 'issues/issue_with_comment.html.raw'; @@ -34,7 +51,9 @@ import '~/notes'; }); it('modifies the Markdown field', function() { - $('input[type=checkbox]').attr('checked', true).trigger('change'); + const changeEvent = document.createEvent('HTMLEvents'); + changeEvent.initEvent('change', true, true); + $('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent); expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); }); @@ -128,7 +147,6 @@ import '~/notes'; beforeEach(() => { note = { id: 1, - discussion_html: null, valid: true, note: 'heya', html: '<div>heya</div>', @@ -444,11 +462,17 @@ import '~/notes'; }); describe('getFormData', () => { - it('should return form metadata object from form reference', () => { + let $form; + let sampleComment; + + beforeEach(() => { this.notes = new Notes('', []); - const $form = $('form'); - const sampleComment = 'foobar'; + $form = $('form'); + sampleComment = 'foobar'; + }); + + it('should return form metadata object from form reference', () => { $form.find('textarea.js-note-text').val(sampleComment); const { formData, formContent, formAction } = this.notes.getFormData($form); @@ -456,6 +480,18 @@ import '~/notes'; expect(formContent).toEqual(sampleComment); expect(formAction).toEqual($form.attr('action')); }); + + it('should return form metadata with sanitized formContent from form reference', () => { + spyOn(_, 'escape').and.callFake(htmlEscape); + + sampleComment = '<script>alert("Boom!");</script>'; + $form.find('textarea.js-note-text').val(sampleComment); + + const { formContent } = this.notes.getFormData($form); + + expect(_.escape).toHaveBeenCalledWith(sampleComment); + expect(formContent).toEqual('<script>alert("Boom!");</script>'); + }); }); describe('hasSlashCommands', () => { @@ -511,30 +547,42 @@ import '~/notes'; }); }); + describe('getSlashCommandDescription', () => { + const availableSlashCommands = [ + { name: 'close', description: 'Close this issue', params: [] }, + { name: 'title', description: 'Change title', params: [{}] }, + { name: 'estimate', description: 'Set time estimate', params: [{}] } + ]; + + beforeEach(() => { + this.notes = new Notes(); + }); + + it('should return executing slash command description when note has single slash command', () => { + const sampleComment = '/close'; + expect(this.notes.getSlashCommandDescription(sampleComment, availableSlashCommands)).toBe('Applying command to close this issue'); + }); + + it('should return generic multiple slash command description when note has multiple slash commands', () => { + const sampleComment = '/close\n/title [Duplicate] Issue foobar'; + expect(this.notes.getSlashCommandDescription(sampleComment, availableSlashCommands)).toBe('Applying multiple commands'); + }); + + it('should return generic slash command description when available slash commands list is not populated', () => { + const sampleComment = '/close\n/title [Duplicate] Issue foobar'; + expect(this.notes.getSlashCommandDescription(sampleComment)).toBe('Applying command'); + }); + }); + describe('createPlaceholderNote', () => { const sampleComment = 'foobar'; const uniqueId = 'b1234-a4567'; const currentUsername = 'root'; const currentUserFullname = 'Administrator'; + const currentUserAvatar = 'avatar_url'; beforeEach(() => { this.notes = new Notes('', []); - spyOn(_, 'escape').and.callFake((comment) => { - const escapedString = comment.replace(/["&'<>]/g, (a) => { - const escapedToken = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '`': '`' - }[a]; - - return escapedToken; - }); - - return escapedString; - }); }); it('should return constructed placeholder element for regular note based on form contents', () => { @@ -543,46 +591,59 @@ import '~/notes'; uniqueId, isDiscussionNote: false, currentUsername, - currentUserFullname + currentUserFullname, + currentUserAvatar, }); const $tempNoteHeader = $tempNote.find('.note-header'); expect($tempNote.prop('nodeName')).toEqual('LI'); expect($tempNote.attr('id')).toEqual(uniqueId); + expect($tempNote.hasClass('being-posted')).toBeTruthy(); + expect($tempNote.hasClass('fade-in-half')).toBeTruthy(); $tempNote.find('.timeline-icon > a, .note-header-info > a').each(function() { expect($(this).attr('href')).toEqual(`/${currentUsername}`); }); + expect($tempNote.find('.timeline-icon .avatar').attr('src')).toEqual(currentUserAvatar); expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeFalsy(); expect($tempNoteHeader.find('.hidden-xs').text().trim()).toEqual(currentUserFullname); expect($tempNoteHeader.find('.note-headline-light').text().trim()).toEqual(`@${currentUsername}`); expect($tempNote.find('.note-body .note-text p').text().trim()).toEqual(sampleComment); }); - it('should escape HTML characters from note based on form contents', () => { - const commentWithHtml = '<script>alert("Boom!");</script>'; + it('should return constructed placeholder element for discussion note based on form contents', () => { const $tempNote = this.notes.createPlaceholderNote({ - formContent: commentWithHtml, + formContent: sampleComment, uniqueId, - isDiscussionNote: false, + isDiscussionNote: true, currentUsername, currentUserFullname }); - expect(_.escape).toHaveBeenCalledWith(commentWithHtml); - expect($tempNote.find('.note-body .note-text p').html()).toEqual('<script>alert("Boom!");</script>'); + expect($tempNote.prop('nodeName')).toEqual('LI'); + expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy(); }); + }); - it('should return constructed placeholder element for discussion note based on form contents', () => { - const $tempNote = this.notes.createPlaceholderNote({ - formContent: sampleComment, + describe('createPlaceholderSystemNote', () => { + const sampleCommandDescription = 'Applying command to close this issue'; + const uniqueId = 'b1234-a4567'; + + beforeEach(() => { + this.notes = new Notes('', []); + spyOn(_, 'escape').and.callFake(htmlEscape); + }); + + it('should return constructed placeholder element for system note based on form contents', () => { + const $tempNote = this.notes.createPlaceholderSystemNote({ + formContent: sampleCommandDescription, uniqueId, - isDiscussionNote: true, - currentUsername, - currentUserFullname }); expect($tempNote.prop('nodeName')).toEqual('LI'); - expect($tempNote.find('.timeline-content').hasClass('discussion')).toBeTruthy(); + expect($tempNote.attr('id')).toEqual(uniqueId); + expect($tempNote.hasClass('being-posted')).toBeTruthy(); + expect($tempNote.hasClass('fade-in-half')).toBeTruthy(); + expect($tempNote.find('.timeline-content i').text().trim()).toEqual(sampleCommandDescription); }); }); @@ -594,7 +655,7 @@ import '~/notes'; it('shows a flash message', () => { this.notes.addFlash('Error message', FLASH_TYPE_ALERT, this.notes.parentTimeline); - expect(document.querySelectorAll('.flash-alert').length).toBe(1); + expect($('.flash-alert').is(':visible')).toBeTruthy(); }); }); @@ -604,13 +665,12 @@ import '~/notes'; this.notes = new Notes(); }); - it('removes all the associated flash messages', () => { + it('hides visible flash message', () => { this.notes.addFlash('Error message 1', FLASH_TYPE_ALERT, this.notes.parentTimeline); - this.notes.addFlash('Error message 2', FLASH_TYPE_ALERT, this.notes.parentTimeline); this.notes.clearFlash(); - expect(document.querySelectorAll('.flash-alert').length).toBe(0); + expect($('.flash-alert').is(':visible')).toBeFalsy(); }); }); }); diff --git a/spec/javascripts/pipelines/graph/action_component_spec.js b/spec/javascripts/pipelines/graph/action_component_spec.js index f033956c071..85bd87318db 100644 --- a/spec/javascripts/pipelines/graph/action_component_spec.js +++ b/spec/javascripts/pipelines/graph/action_component_spec.js @@ -4,7 +4,7 @@ import actionComponent from '~/pipelines/components/graph/action_component.vue'; describe('pipeline graph action component', () => { let component; - beforeEach(() => { + beforeEach((done) => { const ActionComponent = Vue.extend(actionComponent); component = new ActionComponent({ propsData: { @@ -14,6 +14,8 @@ describe('pipeline graph action component', () => { actionIcon: 'icon_action_cancel', }, }).$mount(); + + Vue.nextTick(done); }); it('should render a link', () => { @@ -27,7 +29,7 @@ describe('pipeline graph action component', () => { it('should update bootstrap tooltip when title changes', (done) => { component.tooltipText = 'changed'; - Vue.nextTick(() => { + setTimeout(() => { expect(component.$el.getAttribute('data-original-title')).toBe('changed'); done(); }); diff --git a/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js b/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js index 14ff1b0d25c..25fd18b197e 100644 --- a/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js +++ b/spec/javascripts/pipelines/graph/dropdown_action_component_spec.js @@ -4,7 +4,7 @@ import dropdownActionComponent from '~/pipelines/components/graph/dropdown_actio describe('action component', () => { let component; - beforeEach(() => { + beforeEach((done) => { const DropdownActionComponent = Vue.extend(dropdownActionComponent); component = new DropdownActionComponent({ propsData: { @@ -14,6 +14,8 @@ describe('action component', () => { actionIcon: 'icon_action_cancel', }, }).$mount(); + + Vue.nextTick(done); }); it('should render a link', () => { diff --git a/spec/javascripts/pipelines/graph/graph_component_spec.js b/spec/javascripts/pipelines/graph/graph_component_spec.js index 6bd0eb86263..713baa65a17 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/graph/job_component_spec.js b/spec/javascripts/pipelines/graph/job_component_spec.js index 63986b6c0db..e90593e0f40 100644 --- a/spec/javascripts/pipelines/graph/job_component_spec.js +++ b/spec/javascripts/pipelines/graph/job_component_spec.js @@ -27,26 +27,30 @@ describe('pipeline graph job component', () => { }); describe('name with link', () => { - it('should render the job name and status with a link', () => { + it('should render the job name and status with a link', (done) => { const component = new JobComponent({ propsData: { job: mockJob, }, }).$mount(); - const link = component.$el.querySelector('a'); + Vue.nextTick(() => { + const link = component.$el.querySelector('a'); - expect(link.getAttribute('href')).toEqual(mockJob.status.details_path); + expect(link.getAttribute('href')).toEqual(mockJob.status.details_path); - expect( - link.getAttribute('data-original-title'), - ).toEqual(`${mockJob.name} - ${mockJob.status.label}`); + expect( + link.getAttribute('data-original-title'), + ).toEqual(`${mockJob.name} - ${mockJob.status.label}`); - expect(component.$el.querySelector('.js-status-icon-success')).toBeDefined(); + expect(component.$el.querySelector('.js-status-icon-success')).toBeDefined(); - expect( - component.$el.querySelector('.ci-status-text').textContent.trim(), - ).toEqual(mockJob.name); + expect( + component.$el.querySelector('.ci-status-text').textContent.trim(), + ).toEqual(mockJob.name); + + done(); + }); }); }); diff --git a/spec/javascripts/pipelines/header_component_spec.js b/spec/javascripts/pipelines/header_component_spec.js new file mode 100644 index 00000000000..b8531350e43 --- /dev/null +++ b/spec/javascripts/pipelines/header_component_spec.js @@ -0,0 +1,60 @@ +import Vue from 'vue'; +import headerComponent from '~/pipelines/components/header_component.vue'; +import eventHub from '~/pipelines/event_hub'; + +describe('Pipeline details header', () => { + let HeaderComponent; + let vm; + let props; + + beforeEach(() => { + HeaderComponent = Vue.extend(headerComponent); + + props = { + pipeline: { + details: { + status: { + group: 'failed', + icon: 'ci-status-failed', + label: 'failed', + text: 'failed', + details_path: 'path', + }, + }, + id: 123, + created_at: '2017-05-08T14:57:39.781Z', + user: { + web_url: 'path', + name: 'Foo', + username: 'foobar', + email: 'foo@bar.com', + avatar_url: 'link', + }, + retry_path: 'path', + }, + isLoading: false, + }; + + vm = new HeaderComponent({ propsData: props }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render provided pipeline info', () => { + expect( + vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(), + ).toEqual('failed Pipeline #123 triggered 3 weeks ago by Foo'); + }); + + describe('action buttons', () => { + it('should call postAction when button action is clicked', () => { + eventHub.$on('headerPostAction', (action) => { + expect(action.path).toEqual('path'); + }); + + vm.$el.querySelector('button').click(); + }); + }); +}); diff --git a/spec/javascripts/pipelines/pipeline_details_mediator_spec.js b/spec/javascripts/pipelines/pipeline_details_mediator_spec.js new file mode 100644 index 00000000000..9fec2f61f78 --- /dev/null +++ b/spec/javascripts/pipelines/pipeline_details_mediator_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import PipelineMediator from '~/pipelines/pipeline_details_mediatior'; + +describe('PipelineMdediator', () => { + let mediator; + beforeEach(() => { + mediator = new PipelineMediator({ endpoint: 'foo' }); + }); + + it('should set defaults', () => { + expect(mediator.options).toEqual({ endpoint: 'foo' }); + expect(mediator.state.isLoading).toEqual(false); + expect(mediator.store).toBeDefined(); + expect(mediator.service).toBeDefined(); + }); + + describe('request and store data', () => { + const interceptor = (request, next) => { + next(request.respondWith(JSON.stringify({ foo: 'bar' }), { + status: 200, + })); + }; + + beforeEach(() => { + Vue.http.interceptors.push(interceptor); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptor, interceptor); + }); + + it('should store received data', (done) => { + mediator.fetchPipeline(); + + setTimeout(() => { + expect(mediator.store.state.pipeline).toEqual({ foo: 'bar' }); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/pipelines/pipeline_store_spec.js b/spec/javascripts/pipelines/pipeline_store_spec.js new file mode 100644 index 00000000000..85d13445b01 --- /dev/null +++ b/spec/javascripts/pipelines/pipeline_store_spec.js @@ -0,0 +1,27 @@ +import PipelineStore from '~/pipelines/stores/pipeline_store'; + +describe('Pipeline Store', () => { + let store; + + beforeEach(() => { + store = new PipelineStore(); + }); + + it('should set defaults', () => { + expect(store.state).toEqual({ pipeline: {} }); + expect(store.state.pipeline).toEqual({}); + }); + + describe('storePipeline', () => { + it('should store empty object if none is provided', () => { + store.storePipeline(); + + expect(store.state.pipeline).toEqual({}); + }); + + it('should store received object', () => { + store.storePipeline({ foo: 'bar' }); + expect(store.state.pipeline).toEqual({ foo: 'bar' }); + }); + }); +}); diff --git a/spec/javascripts/pipelines/pipeline_url_spec.js b/spec/javascripts/pipelines/pipeline_url_spec.js index 0bcc3905702..594a9856d2c 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; @@ -47,6 +47,7 @@ describe('Pipeline Url Component', () => { web_url: '/', name: 'foo', avatar_url: '/', + path: '/', }, }, }; 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 da9dff18ada..2c3d0ddff28 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 d043ad38b8b..732b516badd 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 bdc18243a15..3a0c50b750f 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/commit_spec.js b/spec/javascripts/vue_shared/components/commit_spec.js index 0638483e7aa..050170a54e9 100644 --- a/spec/javascripts/vue_shared/components/commit_spec.js +++ b/spec/javascripts/vue_shared/components/commit_spec.js @@ -24,6 +24,7 @@ describe('Commit component', () => { author: { avatar_url: 'https://gitlab.com/uploads/user/avatar/300478/avatar.png', web_url: 'https://gitlab.com/jschatz1', + path: '/jschatz1', username: 'jschatz1', }, }, @@ -46,6 +47,7 @@ describe('Commit component', () => { author: { avatar_url: 'https://gitlab.com/uploads/user/avatar/300478/avatar.png', web_url: 'https://gitlab.com/jschatz1', + path: '/jschatz1', username: 'jschatz1', }, commitIconSvg: '<svg></svg>', @@ -81,7 +83,7 @@ describe('Commit component', () => { it('should render a link to the author profile', () => { expect( component.$el.querySelector('.commit-title .avatar-image-container').getAttribute('href'), - ).toEqual(props.author.web_url); + ).toEqual(props.author.path); }); it('Should render the author avatar with title and alt attributes', () => { 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 00000000000..2b51c89f311 --- /dev/null +++ b/spec/javascripts/vue_shared/components/header_ci_component_spec.js @@ -0,0 +1,93 @@ +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', + isLoading: false, + }, + { + label: 'Go', + path: 'path', + type: 'link', + cssClass: 'link', + isLoading: false, + }, + ], + }; + + 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); + }); + + it('should show loading icon', (done) => { + vm.actions[0].isLoading = true; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.btn .fa-spinner').getAttribute('style')).toEqual(''); + done(); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/markdown/field_spec.js b/spec/javascripts/vue_shared/components/markdown/field_spec.js new file mode 100644 index 00000000000..4bbaff561fc --- /dev/null +++ b/spec/javascripts/vue_shared/components/markdown/field_spec.js @@ -0,0 +1,121 @@ +import Vue from 'vue'; +import fieldComponent from '~/vue_shared/components/markdown/field.vue'; + +describe('Markdown field component', () => { + let vm; + + beforeEach(() => { + vm = new Vue({ + render(createElement) { + return createElement( + fieldComponent, + { + props: { + markdownPreviewUrl: '/preview', + markdownDocs: '/docs', + }, + }, + [ + createElement('textarea', { + slot: 'textarea', + }), + ], + ); + }, + }); + }); + + it('creates a new instance of GL form', (done) => { + spyOn(gl, 'GLForm'); + vm.$mount(); + + Vue.nextTick(() => { + expect( + gl.GLForm, + ).toHaveBeenCalled(); + + done(); + }); + }); + + describe('mounted', () => { + beforeEach((done) => { + vm.$mount(); + + Vue.nextTick(done); + }); + + it('renders textarea inside backdrop', () => { + expect( + vm.$el.querySelector('.zen-backdrop textarea'), + ).not.toBeNull(); + }); + + describe('markdown preview', () => { + let previewLink; + + beforeEach(() => { + spyOn(Vue.http, 'post').and.callFake(() => new Promise((resolve) => { + resolve({ + json() { + return { + body: '<p>markdown preview</p>', + }; + }, + }); + })); + + previewLink = vm.$el.querySelector('.nav-links li:nth-child(2) a'); + }); + + it('sets preview link as active', (done) => { + previewLink.click(); + + Vue.nextTick(() => { + expect( + previewLink.parentNode.classList.contains('active'), + ).toBeTruthy(); + + done(); + }); + }); + + it('shows preview loading text', (done) => { + previewLink.click(); + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.md-preview').textContent.trim(), + ).toContain('Loading...'); + + done(); + }); + }); + + it('renders markdown preview', (done) => { + previewLink.click(); + + setTimeout(() => { + expect( + vm.$el.querySelector('.md-preview').innerHTML, + ).toContain('<p>markdown preview</p>'); + + done(); + }); + }); + + it('renders GFM with jQuery', (done) => { + spyOn($.fn, 'renderGFM'); + previewLink.click(); + + setTimeout(() => { + expect( + $.fn.renderGFM, + ).toHaveBeenCalled(); + + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/markdown/header_spec.js b/spec/javascripts/vue_shared/components/markdown/header_spec.js new file mode 100644 index 00000000000..7110ff36937 --- /dev/null +++ b/spec/javascripts/vue_shared/components/markdown/header_spec.js @@ -0,0 +1,67 @@ +import Vue from 'vue'; +import headerComponent from '~/vue_shared/components/markdown/header.vue'; + +describe('Markdown field header component', () => { + let vm; + + beforeEach((done) => { + const Component = Vue.extend(headerComponent); + + vm = new Component({ + propsData: { + previewMarkdown: false, + }, + }).$mount(); + + Vue.nextTick(done); + }); + + it('renders markdown buttons', () => { + expect( + vm.$el.querySelectorAll('.js-md').length, + ).toBe(7); + }); + + it('renders `write` link as active when previewMarkdown is false', () => { + expect( + vm.$el.querySelector('li:nth-child(1)').classList.contains('active'), + ).toBeTruthy(); + }); + + it('renders `preview` link as active when previewMarkdown is true', (done) => { + vm.previewMarkdown = true; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('li:nth-child(2)').classList.contains('active'), + ).toBeTruthy(); + + done(); + }); + }); + + it('emits toggle markdown event when clicking preview', () => { + spyOn(vm, '$emit'); + + vm.$el.querySelector('li:nth-child(2) a').click(); + + expect( + vm.$emit, + ).toHaveBeenCalledWith('toggle-markdown'); + }); + + it('blurs preview link after click', (done) => { + const link = vm.$el.querySelector('li:nth-child(2) a'); + spyOn(HTMLElement.prototype, 'blur'); + + link.click(); + + setTimeout(() => { + expect( + link.blur, + ).toHaveBeenCalled(); + + done(); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js index 286118917e8..67419cfcbea 100644 --- a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js +++ b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js @@ -76,7 +76,7 @@ describe('Pipelines Table Row', () => { it('should render user information', () => { expect( component.$el.querySelector('td:nth-child(2) a:nth-child(3)').getAttribute('href'), - ).toEqual(pipeline.user.web_url); + ).toEqual(pipeline.user.path); expect( component.$el.querySelector('td:nth-child(2) img').getAttribute('data-original-title'), @@ -120,7 +120,7 @@ describe('Pipelines Table Row', () => { component = buildComponent(pipeline); const { commitAuthorLink, commitAuthorName } = findElements(); - expect(commitAuthorLink).toEqual(pipeline.commit.author.web_url); + expect(commitAuthorLink).toEqual(pipeline.commit.author.path); expect(commitAuthorName).toEqual(pipeline.commit.author.username); }); 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 00000000000..bf28019ef24 --- /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/filter/user_reference_filter_spec.rb b/spec/lib/banzai/filter/user_reference_filter_spec.rb index 63b23dac7ed..edf3846b742 100644 --- a/spec/lib/banzai/filter/user_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/user_reference_filter_spec.rb @@ -16,6 +16,11 @@ describe Banzai::Filter::UserReferenceFilter, lib: true do expect(reference_filter(act).to_html).to eq(exp) end + it 'ignores references with text before the @ sign' do + exp = act = "Hey foo#{reference}" + expect(reference_filter(act).to_html).to eq(exp) + end + %w(pre code a style).each do |elem| it "ignores valid references contained inside '#{elem}' element" do exp = act = "<#{elem}>Hey #{reference}</#{elem}>" diff --git a/spec/lib/banzai/reference_parser/user_parser_spec.rb b/spec/lib/banzai/reference_parser/user_parser_spec.rb index 4ec998efe53..592ed0d2b98 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/feature_spec.rb b/spec/lib/feature_spec.rb new file mode 100644 index 00000000000..1d92a5cb33f --- /dev/null +++ b/spec/lib/feature_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe Feature, lib: true do + describe '.get' do + let(:feature) { double(:feature) } + let(:key) { 'my_feature' } + + it 'returns the Flipper feature' do + expect_any_instance_of(Flipper::DSL).to receive(:feature).with(key). + and_return(feature) + + expect(described_class.get(key)).to be(feature) + end + end + + describe '.all' do + let(:features) { Set.new } + + it 'returns the Flipper features as an array' do + expect_any_instance_of(Flipper::DSL).to receive(:features). + and_return(features) + + expect(described_class.all).to eq(features.to_a) + end + end +end 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 b386852b196..cfb5cba054e 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/chat_commands/command_spec.rb b/spec/lib/gitlab/chat_commands/command_spec.rb index eb4f06b371c..13e6953147b 100644 --- a/spec/lib/gitlab/chat_commands/command_spec.rb +++ b/spec/lib/gitlab/chat_commands/command_spec.rb @@ -58,9 +58,12 @@ describe Gitlab::ChatCommands::Command, service: true do end end - context 'and user does have deployment permission' do + context 'and user has deployment permission' do before do - build.project.add_master(user) + build.project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) end it 'returns action' do diff --git a/spec/lib/gitlab/chat_commands/deploy_spec.rb b/spec/lib/gitlab/chat_commands/deploy_spec.rb index b33389d959e..46dbdeae37c 100644 --- a/spec/lib/gitlab/chat_commands/deploy_spec.rb +++ b/spec/lib/gitlab/chat_commands/deploy_spec.rb @@ -7,7 +7,12 @@ describe Gitlab::ChatCommands::Deploy, service: true do let(:regex_match) { described_class.match('deploy staging to production') } before do - project.add_master(user) + # Make it possible to trigger protected manual actions for developers. + # + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) end subject do diff --git a/spec/lib/gitlab/ci/status/build/common_spec.rb b/spec/lib/gitlab/ci/status/build/common_spec.rb index 40b96b1807b..72bd7c4eb93 100644 --- a/spec/lib/gitlab/ci/status/build/common_spec.rb +++ b/spec/lib/gitlab/ci/status/build/common_spec.rb @@ -31,7 +31,7 @@ describe Gitlab::Ci::Status::Build::Common do describe '#details_path' do it 'links to the build details page' do - expect(subject.details_path).to include "builds/#{build.id}" + expect(subject.details_path).to include "jobs/#{build.id}" end end end diff --git a/spec/lib/gitlab/ci/status/build/factory_spec.rb b/spec/lib/gitlab/ci/status/build/factory_spec.rb index 185bb9098da..3f30b2c38f2 100644 --- a/spec/lib/gitlab/ci/status/build/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/build/factory_spec.rb @@ -224,7 +224,10 @@ describe Gitlab::Ci::Status::Build::Factory do context 'when user has ability to play action' do before do - build.project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) end it 'fabricates status that has action' do diff --git a/spec/lib/gitlab/ci/status/build/play_spec.rb b/spec/lib/gitlab/ci/status/build/play_spec.rb index f5d0f977768..0e15a5f3c6b 100644 --- a/spec/lib/gitlab/ci/status/build/play_spec.rb +++ b/spec/lib/gitlab/ci/status/build/play_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe Gitlab::Ci::Status::Build::Play do let(:user) { create(:user) } + let(:project) { build.project } let(:build) { create(:ci_build, :manual) } let(:status) { Gitlab::Ci::Status::Core.new(build, user) } @@ -15,8 +16,13 @@ describe Gitlab::Ci::Status::Build::Play do describe '#has_action?' do context 'when user is allowed to update build' do - context 'when user can push to branch' do - before { build.project.add_master(user) } + context 'when user is allowed to trigger protected action' do + before do + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) + end it { is_expected.to have_action } end diff --git a/spec/lib/gitlab/ci/trace/stream_spec.rb b/spec/lib/gitlab/ci/trace/stream_spec.rb index 40ac5a3ed37..bbb3f9912a3 100644 --- a/spec/lib/gitlab/ci/trace/stream_spec.rb +++ b/spec/lib/gitlab/ci/trace/stream_spec.rb @@ -240,9 +240,50 @@ describe Gitlab::Ci::Trace::Stream do end context 'multiple results in content & regex' do - let(:data) { ' (98.39%) covered. (98.29%) covered' } + let(:data) do + <<~HEREDOC + (98.39%) covered + (98.29%) covered + HEREDOC + end + + let(:regex) { '\(\d+.\d+\%\) covered' } + + it 'returns the last matched coverage' do + is_expected.to eq("98.29") + end + end + + context 'when BUFFER_SIZE is smaller than stream.size' do + let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' } + let(:regex) { '\(\d+.\d+\%\) covered' } + + before do + stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', 5) + end + + it { is_expected.to eq("98.29") } + end + + context 'when regex is multi-byte char' do + let(:data) { '95.0 ゴッドファット\n' } + let(:regex) { '\d+\.\d+ ゴッドファット' } + + before do + stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', 5) + end + + it { is_expected.to eq('95.0') } + end + + context 'when BUFFER_SIZE is equal to stream.size' do + let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' } let(:regex) { '\(\d+.\d+\%\) covered' } + before do + stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', data.length) + end + it { is_expected.to eq("98.29") } end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index bd5ac6142be..3fdafd867da 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -66,16 +66,23 @@ describe Gitlab::Database::MigrationHelpers, lib: true do context 'using PostgreSQL' do before do - allow(Gitlab::Database).to receive(:postgresql?).and_return(true) + allow(model).to receive(:supports_drop_index_concurrently?).and_return(true) allow(model).to receive(:disable_statement_timeout) end - it 'removes the index concurrently' do + it 'removes the index concurrently by column name' do expect(model).to receive(:remove_index). with(:users, { algorithm: :concurrently, column: :foo }) model.remove_concurrent_index(:users, :foo) end + + it 'removes the index concurrently by index name' do + expect(model).to receive(:remove_index). + with(:users, { algorithm: :concurrently, name: "index_x_by_y" }) + + model.remove_concurrent_index_by_name(:users, "index_x_by_y") + end end context 'using MySQL' do diff --git a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb index c56fded7516..ce2b5d620fd 100644 --- a/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb +++ b/spec/lib/gitlab/database/rename_reserved_paths_migration/v1/rename_namespaces_spec.rb @@ -18,8 +18,8 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do let(:subject) { described_class.new(['parent/the-Path'], migration) } it 'includes the namespace' do - parent = create(:namespace, path: 'parent') - child = create(:namespace, path: 'the-path', parent: parent) + parent = create(:group, path: 'parent') + child = create(:group, path: 'the-path', parent: parent) found_ids = subject.namespaces_for_paths(type: :child). map(&:id) @@ -30,13 +30,13 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do context 'for child namespaces' do it 'only returns child namespaces with the correct path' do - _root_namespace = create(:namespace, path: 'THE-path') - _other_path = create(:namespace, + _root_namespace = create(:group, path: 'THE-path') + _other_path = create(:group, path: 'other', - parent: create(:namespace)) - namespace = create(:namespace, + parent: create(:group)) + namespace = create(:group, path: 'the-path', - parent: create(:namespace)) + parent: create(:group)) found_ids = subject.namespaces_for_paths(type: :child). map(&:id) @@ -45,13 +45,13 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do end it 'has no namespaces that look the same' do - _root_namespace = create(:namespace, path: 'THE-path') - _similar_path = create(:namespace, + _root_namespace = create(:group, path: 'THE-path') + _similar_path = create(:group, path: 'not-really-the-path', - parent: create(:namespace)) - namespace = create(:namespace, + parent: create(:group)) + namespace = create(:group, path: 'the-path', - parent: create(:namespace)) + parent: create(:group)) found_ids = subject.namespaces_for_paths(type: :child). map(&:id) @@ -62,11 +62,11 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do context 'for top levelnamespaces' do it 'only returns child namespaces with the correct path' do - root_namespace = create(:namespace, path: 'the-path') - _other_path = create(:namespace, path: 'other') - _child_namespace = create(:namespace, + root_namespace = create(:group, path: 'the-path') + _other_path = create(:group, path: 'other') + _child_namespace = create(:group, path: 'the-path', - parent: create(:namespace)) + parent: create(:group)) found_ids = subject.namespaces_for_paths(type: :top_level). map(&:id) @@ -75,11 +75,11 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do end it 'has no namespaces that just look the same' do - root_namespace = create(:namespace, path: 'the-path') - _similar_path = create(:namespace, path: 'not-really-the-path') - _child_namespace = create(:namespace, + root_namespace = create(:group, path: 'the-path') + _similar_path = create(:group, path: 'not-really-the-path') + _child_namespace = create(:group, path: 'the-path', - parent: create(:namespace)) + parent: create(:group)) found_ids = subject.namespaces_for_paths(type: :top_level). map(&:id) @@ -124,10 +124,10 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do describe "#child_ids_for_parent" do it "collects child ids for all levels" do - parent = create(:namespace) - first_child = create(:namespace, parent: parent) - second_child = create(:namespace, parent: parent) - third_child = create(:namespace, parent: second_child) + parent = create(:group) + first_child = create(:group, parent: parent) + second_child = create(:group, parent: parent) + third_child = create(:group, parent: second_child) all_ids = [parent.id, first_child.id, second_child.id, third_child.id] collected_ids = subject.child_ids_for_parent(parent, ids: [parent.id]) @@ -205,9 +205,9 @@ describe Gitlab::Database::RenameReservedPathsMigration::V1::RenameNamespaces do end describe '#rename_namespaces' do - let!(:top_level_namespace) { create(:namespace, path: 'the-path') } + let!(:top_level_namespace) { create(:group, path: 'the-path') } let!(:child_namespace) do - create(:namespace, path: 'the-path', parent: create(:namespace)) + create(:group, path: 'the-path', parent: create(:group)) end it 'renames top level namespaces the namespace' do diff --git a/spec/lib/gitlab/dependency_linker/cartfile_linker_spec.rb b/spec/lib/gitlab/dependency_linker/cartfile_linker_spec.rb new file mode 100644 index 00000000000..df77f4037af --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/cartfile_linker_spec.rb @@ -0,0 +1,74 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::CartfileLinker, lib: true do + describe '.support?' do + it 'supports Cartfile' do + expect(described_class.support?('Cartfile')).to be_truthy + end + + it 'supports Cartfile.private' do + expect(described_class.support?('Cartfile.private')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('test.Cartfile')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "Cartfile" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + # Require version 2.3.1 or later + github "ReactiveCocoa/ReactiveCocoa" >= 2.3.1 + + # Require version 1.x + github "Mantle/Mantle" ~> 1.0 # (1.0 or later, but less than 2.0) + + # Require exactly version 0.4.1 + github "jspahrsummers/libextobjc" == 0.4.1 + + # Use the latest version + github "jspahrsummers/xcconfigs" + + # Use the branch + github "jspahrsummers/xcconfigs" "branch" + + # Use a project from GitHub Enterprise + github "https://enterprise.local/ghe/desktop/git-error-translations" + + # Use a project from any arbitrary server, on the "development" branch + git "https://enterprise.local/desktop/git-error-translations2.git" "development" + + # Use a local project + git "file:///directory/to/project" "branch" + + # A binary only framework + binary "https://my.domain.com/release/MyFramework.json" ~> 2.3 + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links dependencies' do + expect(subject).to include(link('ReactiveCocoa/ReactiveCocoa', 'https://github.com/ReactiveCocoa/ReactiveCocoa')) + expect(subject).to include(link('Mantle/Mantle', 'https://github.com/Mantle/Mantle')) + expect(subject).to include(link('jspahrsummers/libextobjc', 'https://github.com/jspahrsummers/libextobjc')) + expect(subject).to include(link('jspahrsummers/xcconfigs', 'https://github.com/jspahrsummers/xcconfigs')) + end + + it 'links Git repos' do + expect(subject).to include(link('https://enterprise.local/ghe/desktop/git-error-translations', 'https://enterprise.local/ghe/desktop/git-error-translations')) + expect(subject).to include(link('https://enterprise.local/desktop/git-error-translations2.git', 'https://enterprise.local/desktop/git-error-translations2.git')) + end + + it 'links binary-only frameworks' do + expect(subject).to include(link('https://my.domain.com/release/MyFramework.json', 'https://my.domain.com/release/MyFramework.json')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/composer_json_linker_spec.rb b/spec/lib/gitlab/dependency_linker/composer_json_linker_spec.rb new file mode 100644 index 00000000000..d7a926e800f --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/composer_json_linker_spec.rb @@ -0,0 +1,82 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::ComposerJsonLinker, lib: true do + describe '.support?' do + it 'supports composer.json' do + expect(described_class.support?('composer.json')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('composer.json.example')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "composer.json" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + { + "name": "laravel/laravel", + "homepage": "https://laravel.com/", + "description": "The Laravel Framework.", + "keywords": ["framework", "laravel"], + "license": "MIT", + "type": "project", + "repositories": [ + { + "type": "git", + "url": "https://github.com/laravel/laravel.git" + } + ], + "require": { + "php": ">=5.5.9", + "laravel/framework": "5.2.*" + }, + "require-dev": { + "fzaninotto/faker": "~1.4", + "mockery/mockery": "0.9.*", + "phpunit/phpunit": "~4.0", + "symfony/css-selector": "2.8.*|3.0.*", + "symfony/dom-crawler": "2.8.*|3.0.*" + } + } + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links the module name' do + expect(subject).to include(link('laravel/laravel', 'https://packagist.org/packages/laravel/laravel')) + end + + it 'links the homepage' do + expect(subject).to include(link('https://laravel.com/', 'https://laravel.com/')) + end + + it 'links the repository URL' do + expect(subject).to include(link('https://github.com/laravel/laravel.git', 'https://github.com/laravel/laravel.git')) + end + + it 'links the license' do + expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/')) + end + + it 'links dependencies' do + expect(subject).to include(link('laravel/framework', 'https://packagist.org/packages/laravel/framework')) + expect(subject).to include(link('fzaninotto/faker', 'https://packagist.org/packages/fzaninotto/faker')) + expect(subject).to include(link('mockery/mockery', 'https://packagist.org/packages/mockery/mockery')) + expect(subject).to include(link('phpunit/phpunit', 'https://packagist.org/packages/phpunit/phpunit')) + expect(subject).to include(link('symfony/css-selector', 'https://packagist.org/packages/symfony/css-selector')) + expect(subject).to include(link('symfony/dom-crawler', 'https://packagist.org/packages/symfony/dom-crawler')) + end + + it 'does not link core dependencies' do + expect(subject).not_to include(link('php', 'https://packagist.org/packages/php')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/gemfile_linker_spec.rb b/spec/lib/gitlab/dependency_linker/gemfile_linker_spec.rb index 2e52097a946..3f8335f03ea 100644 --- a/spec/lib/gitlab/dependency_linker/gemfile_linker_spec.rb +++ b/spec/lib/gitlab/dependency_linker/gemfile_linker_spec.rb @@ -33,7 +33,7 @@ describe Gitlab::DependencyLinker::GemfileLinker, lib: true do subject { Gitlab::Highlight.highlight(file_name, file_content) } def link(name, url) - %{<a href="#{url}" rel="noopener noreferrer" target="_blank">#{name}</a>} + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} end it 'links sources' do diff --git a/spec/lib/gitlab/dependency_linker/gemspec_linker_spec.rb b/spec/lib/gitlab/dependency_linker/gemspec_linker_spec.rb new file mode 100644 index 00000000000..d4a71403939 --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/gemspec_linker_spec.rb @@ -0,0 +1,66 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::GemspecLinker, lib: true do + describe '.support?' do + it 'supports *.gemspec' do + expect(described_class.support?('gitlab_git.gemspec')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('.gemspec.example')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "gitlab_git.gemspec" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + Gem::Specification.new do |s| + s.name = 'gitlab_git' + s.version = `cat VERSION` + s.date = Time.now.strftime('%Y-%m-%d') + s.summary = "Gitlab::Git library" + s.description = "GitLab wrapper around git objects" + s.authors = ["Dmitriy Zaporozhets"] + s.email = 'dmitriy.zaporozhets@gmail.com' + s.license = 'MIT' + s.files = `git ls-files lib/`.split('\n') << 'VERSION' + s.homepage = 'https://gitlab.com/gitlab-org/gitlab_git' + + s.add_dependency('github-linguist', '~> 4.7.0') + s.add_dependency('activesupport', '~> 4.0') + s.add_dependency('rugged', '~> 0.24.0') + s.add_runtime_dependency('charlock_holmes', '~> 0.7.3') + s.add_development_dependency('listen', '~> 3.0.6') + end + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links the gem name' do + expect(subject).to include(link('gitlab_git', 'https://rubygems.org/gems/gitlab_git')) + end + + it 'links the license' do + expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/')) + end + + it 'links the homepage' do + expect(subject).to include(link('https://gitlab.com/gitlab-org/gitlab_git', 'https://gitlab.com/gitlab-org/gitlab_git')) + end + + it 'links dependencies' do + expect(subject).to include(link('github-linguist', 'https://rubygems.org/gems/github-linguist')) + expect(subject).to include(link('activesupport', 'https://rubygems.org/gems/activesupport')) + expect(subject).to include(link('rugged', 'https://rubygems.org/gems/rugged')) + expect(subject).to include(link('charlock_holmes', 'https://rubygems.org/gems/charlock_holmes')) + expect(subject).to include(link('listen', 'https://rubygems.org/gems/listen')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/godeps_json_linker_spec.rb b/spec/lib/gitlab/dependency_linker/godeps_json_linker_spec.rb new file mode 100644 index 00000000000..e279e0c9019 --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/godeps_json_linker_spec.rb @@ -0,0 +1,84 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::GodepsJsonLinker, lib: true do + describe '.support?' do + it 'supports Godeps.json' do + expect(described_class.support?('Godeps.json')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('Godeps.json.example')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "Godeps.json" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + { + "ImportPath": "gitlab.com/gitlab-org/gitlab-pages", + "GoVersion": "go1.5", + "Packages": [ + "./..." + ], + "Deps": [ + { + "ImportPath": "github.com/kardianos/osext", + "Rev": "efacde03154693404c65e7aa7d461ac9014acd0c" + }, + { + "ImportPath": "github.com/stretchr/testify/assert", + "Rev": "1297dc01ed0a819ff634c89707081a4df43baf6b" + }, + { + "ImportPath": "github.com/stretchr/testify/require", + "Rev": "1297dc01ed0a819ff634c89707081a4df43baf6b" + }, + { + "ImportPath": "gitlab.com/group/project/path", + "Rev": "1297dc01ed0a819ff634c89707081a4df43baf6b" + }, + { + "ImportPath": "gitlab.com/group/subgroup/project.git/path", + "Rev": "1297dc01ed0a819ff634c89707081a4df43baf6b" + }, + { + "ImportPath": "golang.org/x/crypto/ssh/terminal", + "Rev": "1351f936d976c60a0a48d728281922cf63eafb8d" + }, + { + "ImportPath": "golang.org/x/net/http2", + "Rev": "b4e17d61b15679caf2335da776c614169a1b4643" + } + ] + } + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links the package name' do + expect(subject).to include(link('gitlab.com/gitlab-org/gitlab-pages', 'https://gitlab.com/gitlab-org/gitlab-pages')) + end + + it 'links GitHub repos' do + expect(subject).to include(link('github.com/kardianos/osext', 'https://github.com/kardianos/osext')) + expect(subject).to include(link('github.com/stretchr/testify/assert', 'https://github.com/stretchr/testify/tree/master/assert')) + expect(subject).to include(link('github.com/stretchr/testify/require', 'https://github.com/stretchr/testify/tree/master/require')) + end + + it 'links GitLab projects' do + expect(subject).to include(link('gitlab.com/group/project/path', 'https://gitlab.com/group/project/tree/master/path')) + expect(subject).to include(link('gitlab.com/group/subgroup/project.git/path', 'https://gitlab.com/group/subgroup/project/tree/master/path')) + end + + it 'links Golang packages' do + expect(subject).to include(link('golang.org/x/net/http2', 'https://godoc.org/golang.org/x/net/http2')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/package_json_linker_spec.rb b/spec/lib/gitlab/dependency_linker/package_json_linker_spec.rb new file mode 100644 index 00000000000..8c979ae1869 --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/package_json_linker_spec.rb @@ -0,0 +1,94 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::PackageJsonLinker, lib: true do + describe '.support?' do + it 'supports package.json' do + expect(described_class.support?('package.json')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('package.json.example')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "package.json" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + { + "name": "module-name", + "version": "10.3.1", + "repository": { + "type": "git", + "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", + "karma": "^1.4.1" + }, + "devDependencies": { + "vows": "^0.7.0", + "assume": "<1.0.0 || >=2.3.1 <2.4.5 || >=2.5.2 <3.0.0", + "pre-commit": "*" + }, + "license": "MIT" + } + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links the module name' do + expect(subject).to include(link('module-name', 'https://npmjs.com/package/module-name')) + end + + it 'links the homepage' do + expect(subject).to include(link('https://github.com/vuejs/vue#readme', 'https://github.com/vuejs/vue#readme')) + end + + it 'links the repository URL' do + expect(subject).to include(link('https://github.com/vuejs/vue.git', 'https://github.com/vuejs/vue.git')) + end + + it 'links the license' do + expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/')) + end + + it 'links dependencies' do + expect(subject).to include(link('primus', 'https://npmjs.com/package/primus')) + expect(subject).to include(link('async', 'https://npmjs.com/package/async')) + 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')) + end + + it 'links GitHub repos' do + expect(subject).to include(link('bigpipe/pagelet', 'https://github.com/bigpipe/pagelet')) + end + + 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/dependency_linker/podfile_linker_spec.rb b/spec/lib/gitlab/dependency_linker/podfile_linker_spec.rb new file mode 100644 index 00000000000..06007cf97f7 --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/podfile_linker_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::PodfileLinker, lib: true do + describe '.support?' do + it 'supports Podfile' do + expect(described_class.support?('Podfile')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('Podfile.lock')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "Podfile" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + source 'https://github.com/artsy/Specs.git' + source 'https://github.com/CocoaPods/Specs.git' + + platform :ios, '8.0' + use_frameworks! + inhibit_all_warnings! + + target 'Artsy' do + pod 'AFNetworking', "~> 2.5" + pod 'Interstellar/Core', git: 'https://github.com/ashfurrow/Interstellar.git', branch: 'observable-unsubscribe' + end + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links sources' do + expect(subject).to include(link('https://github.com/artsy/Specs.git', 'https://github.com/artsy/Specs.git')) + expect(subject).to include(link('https://github.com/CocoaPods/Specs.git', 'https://github.com/CocoaPods/Specs.git')) + end + + it 'links packages' do + expect(subject).to include(link('AFNetworking', 'https://cocoapods.org/pods/AFNetworking')) + expect(subject).to include(link('Interstellar/Core', 'https://cocoapods.org/pods/Interstellar')) + end + + it 'links Git repos' do + expect(subject).to include(link('https://github.com/ashfurrow/Interstellar.git', 'https://github.com/ashfurrow/Interstellar.git')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/podspec_json_linker_spec.rb b/spec/lib/gitlab/dependency_linker/podspec_json_linker_spec.rb new file mode 100644 index 00000000000..d722865264b --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/podspec_json_linker_spec.rb @@ -0,0 +1,96 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::PodspecJsonLinker, lib: true do + describe '.support?' do + it 'supports *.podspec.json' do + expect(described_class.support?('Reachability.podspec.json')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('.podspec.json.example')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "AFNetworking.podspec.json" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + { + "name": "AFNetworking", + "version": "2.0.0", + "license": "MIT", + "summary": "A delightful iOS and OS X networking framework.", + "homepage": "https://github.com/AFNetworking/AFNetworking", + "authors": { + "Mattt Thompson": "m@mattt.me" + }, + "source": { + "git": "https://github.com/AFNetworking/AFNetworking.git", + "tag": "2.0.0", + "submodules": true + }, + "requires_arc": true, + "platforms": { + "ios": "6.0", + "osx": "10.8" + }, + "public_header_files": "AFNetworking/*.h", + "subspecs": [ + { + "name": "NSURLConnection", + "dependencies": { + "AFNetworking/Serialization": [ + + ], + "AFNetworking/Reachability": [ + + ], + "AFNetworking/Security": [ + + ] + }, + "source_files": [ + "AFNetworking/AFURLConnectionOperation.{h,m}", + "AFNetworking/AFHTTPRequestOperation.{h,m}", + "AFNetworking/AFHTTPRequestOperationManager.{h,m}" + ] + } + ] + } + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links the gem name' do + expect(subject).to include(link('AFNetworking', 'https://cocoapods.org/pods/AFNetworking')) + end + + it 'links the license' do + expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/')) + end + + it 'links the homepage' do + expect(subject).to include(link('https://github.com/AFNetworking/AFNetworking', 'https://github.com/AFNetworking/AFNetworking')) + end + + it 'links the source URL' do + expect(subject).to include(link('https://github.com/AFNetworking/AFNetworking.git', 'https://github.com/AFNetworking/AFNetworking.git')) + end + + it 'links dependencies' do + expect(subject).to include(link('AFNetworking/Serialization', 'https://cocoapods.org/pods/AFNetworking')) + expect(subject).to include(link('AFNetworking/Reachability', 'https://cocoapods.org/pods/AFNetworking')) + expect(subject).to include(link('AFNetworking/Security', 'https://cocoapods.org/pods/AFNetworking')) + end + + it 'does not link subspec names' do + expect(subject).not_to include(link('NSURLConnection', 'https://cocoapods.org/pods/NSURLConnection')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/podspec_linker_spec.rb b/spec/lib/gitlab/dependency_linker/podspec_linker_spec.rb new file mode 100644 index 00000000000..dfc366b5817 --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/podspec_linker_spec.rb @@ -0,0 +1,69 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::PodspecLinker, lib: true do + describe '.support?' do + it 'supports *.podspec' do + expect(described_class.support?('Reachability.podspec')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('.podspec.example')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "Reachability.podspec" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + Pod::Spec.new do |spec| + spec.name = 'Reachability' + spec.version = '3.1.0' + spec.license = { :type => 'GPL-3.0' } + spec.license = "MIT" + spec.license = { type: 'Apache-2.0' } + spec.homepage = 'https://github.com/tonymillion/Reachability' + spec.authors = { 'Tony Million' => 'tonymillion@gmail.com' } + spec.summary = 'ARC and GCD Compatible Reachability Class for iOS and OS X.' + spec.source = { :git => 'https://github.com/tonymillion/Reachability.git', :tag => 'v3.1.0' } + spec.source_files = 'Reachability.{h,m}' + spec.framework = 'SystemConfiguration' + + spec.dependency 'AFNetworking', '~> 1.0' + spec.dependency 'RestKit/CoreData', '~> 0.20.0' + spec.ios.dependency 'MBProgressHUD', '~> 0.5' + end + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links the gem name' do + expect(subject).to include(link('Reachability', 'https://cocoapods.org/pods/Reachability')) + end + + it 'links the license' do + expect(subject).to include(link('GPL-3.0', 'http://choosealicense.com/licenses/gpl-3.0/')) + expect(subject).to include(link('MIT', 'http://choosealicense.com/licenses/mit/')) + expect(subject).to include(link('Apache-2.0', 'http://choosealicense.com/licenses/apache-2.0/')) + end + + it 'links the homepage' do + expect(subject).to include(link('https://github.com/tonymillion/Reachability', 'https://github.com/tonymillion/Reachability')) + end + + it 'links the source URL' do + expect(subject).to include(link('https://github.com/tonymillion/Reachability.git', 'https://github.com/tonymillion/Reachability.git')) + end + + it 'links dependencies' do + expect(subject).to include(link('AFNetworking', 'https://cocoapods.org/pods/AFNetworking')) + expect(subject).to include(link('RestKit/CoreData', 'https://cocoapods.org/pods/RestKit')) + expect(subject).to include(link('MBProgressHUD', 'https://cocoapods.org/pods/MBProgressHUD')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker/requirements_txt_linker_spec.rb b/spec/lib/gitlab/dependency_linker/requirements_txt_linker_spec.rb new file mode 100644 index 00000000000..4da8821726c --- /dev/null +++ b/spec/lib/gitlab/dependency_linker/requirements_txt_linker_spec.rb @@ -0,0 +1,87 @@ +require 'rails_helper' + +describe Gitlab::DependencyLinker::RequirementsTxtLinker, lib: true do + describe '.support?' do + it 'supports requirements.txt' do + expect(described_class.support?('requirements.txt')).to be_truthy + end + + it 'supports doc-requirements.txt' do + expect(described_class.support?('doc-requirements.txt')).to be_truthy + end + + it 'does not support other files' do + expect(described_class.support?('requirements')).to be_falsey + end + end + + describe '#link' do + let(:file_name) { "requirements.txt" } + + let(:file_content) do + <<-CONTENT.strip_heredoc + # + ####### example-requirements.txt ####### + # + ###### Requirements without Version Specifiers ###### + nose + nose-cov + beautifulsoup4 + # + ###### Requirements with Version Specifiers ###### + # See https://www.python.org/dev/peps/pep-0440/#version-specifiers + docopt == 0.6.1 # Version Matching. Must be version 0.6.1 + keyring >= 4.1.1 # Minimum version 4.1.1 + coverage != 3.5 # Version Exclusion. Anything except version 3.5 + Mopidy-Dirble ~= 1.1 # Compatible release. Same as >= 1.1, == 1.* + # + ###### Refer to other requirements files ###### + -r other-requirements.txt + # + # + ###### A particular file ###### + ./downloads/numpy-1.9.2-cp34-none-win32.whl + http://wxpython.org/Phoenix/snapshot-builds/wxPython_Phoenix-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl + # + ###### Additional Requirements without Version Specifiers ###### + # Same as 1st section, just here to show that you can put things in any order. + rejected + green + # + + Jinja2>=2.3 + Pygments>=1.2 + Sphinx>=1.3 + docutils>=0.7 + markupsafe + CONTENT + end + + subject { Gitlab::Highlight.highlight(file_name, file_content) } + + def link(name, url) + %{<a href="#{url}" rel="nofollow noreferrer noopener" target="_blank">#{name}</a>} + end + + it 'links dependencies' do + expect(subject).to include(link('nose', 'https://pypi.python.org/pypi/nose')) + expect(subject).to include(link('nose-cov', 'https://pypi.python.org/pypi/nose-cov')) + expect(subject).to include(link('beautifulsoup4', 'https://pypi.python.org/pypi/beautifulsoup4')) + expect(subject).to include(link('docopt', 'https://pypi.python.org/pypi/docopt')) + expect(subject).to include(link('keyring', 'https://pypi.python.org/pypi/keyring')) + expect(subject).to include(link('coverage', 'https://pypi.python.org/pypi/coverage')) + expect(subject).to include(link('Mopidy-Dirble', 'https://pypi.python.org/pypi/Mopidy-Dirble')) + expect(subject).to include(link('rejected', 'https://pypi.python.org/pypi/rejected')) + expect(subject).to include(link('green', 'https://pypi.python.org/pypi/green')) + expect(subject).to include(link('Jinja2', 'https://pypi.python.org/pypi/Jinja2')) + expect(subject).to include(link('Pygments', 'https://pypi.python.org/pypi/Pygments')) + expect(subject).to include(link('Sphinx', 'https://pypi.python.org/pypi/Sphinx')) + expect(subject).to include(link('docutils', 'https://pypi.python.org/pypi/docutils')) + expect(subject).to include(link('markupsafe', 'https://pypi.python.org/pypi/markupsafe')) + end + + it 'links URLs' do + expect(subject).to include(link('http://wxpython.org/Phoenix/snapshot-builds/wxPython_Phoenix-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl', 'http://wxpython.org/Phoenix/snapshot-builds/wxPython_Phoenix-3.0.3.dev1820+49a8884-cp34-none-win_amd64.whl')) + end + end +end diff --git a/spec/lib/gitlab/dependency_linker_spec.rb b/spec/lib/gitlab/dependency_linker_spec.rb index 03d5b61d70c..3d1cfbcfbf7 100644 --- a/spec/lib/gitlab/dependency_linker_spec.rb +++ b/spec/lib/gitlab/dependency_linker_spec.rb @@ -9,5 +9,77 @@ describe Gitlab::DependencyLinker, lib: true do described_class.link(blob_name, nil, nil) end + + it 'links using GemspecLinker' do + blob_name = 'gitlab_git.gemspec' + + expect(described_class::GemspecLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using PackageJsonLinker' do + blob_name = 'package.json' + + expect(described_class::PackageJsonLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using ComposerJsonLinker' do + blob_name = 'composer.json' + + expect(described_class::ComposerJsonLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using PodfileLinker' do + blob_name = 'Podfile' + + expect(described_class::PodfileLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using PodspecLinker' do + blob_name = 'Reachability.podspec' + + expect(described_class::PodspecLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using PodspecJsonLinker' do + blob_name = 'AFNetworking.podspec.json' + + expect(described_class::PodspecJsonLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using CartfileLinker' do + blob_name = 'Cartfile' + + expect(described_class::CartfileLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using GodepsJsonLinker' do + blob_name = 'Godeps.json' + + expect(described_class::GodepsJsonLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end + + it 'links using RequirementsTxtLinker' do + blob_name = 'requirements.txt' + + expect(described_class::RequirementsTxtLinker).to receive(:link) + + described_class.link(blob_name, nil, nil) + end end end diff --git a/spec/lib/gitlab/diff/position_spec.rb b/spec/lib/gitlab/diff/position_spec.rb index cdf0af6d7ef..7095104d75c 100644 --- a/spec/lib/gitlab/diff/position_spec.rb +++ b/spec/lib/gitlab/diff/position_spec.rb @@ -22,7 +22,7 @@ describe Gitlab::Diff::Position, lib: true do it "returns the correct diff file" do diff_file = subject.diff_file(project.repository) - expect(diff_file.new_file).to be true + expect(diff_file.new_file?).to be true expect(diff_file.new_path).to eq(subject.new_path) expect(diff_file.diff_refs).to eq(subject.diff_refs) end @@ -314,7 +314,7 @@ describe Gitlab::Diff::Position, lib: true do it "returns the correct diff file" do diff_file = subject.diff_file(project.repository) - expect(diff_file.deleted_file).to be true + expect(diff_file.deleted_file?).to be true expect(diff_file.old_path).to eq(subject.old_path) expect(diff_file.diff_refs).to eq(subject.diff_refs) end @@ -356,7 +356,7 @@ describe Gitlab::Diff::Position, lib: true do it "returns the correct diff file" do diff_file = subject.diff_file(project.repository) - expect(diff_file.new_file).to be true + expect(diff_file.new_file?).to be true expect(diff_file.new_path).to eq(subject.new_path) expect(diff_file.diff_refs).to eq(subject.diff_refs) end diff --git a/spec/lib/gitlab/git/encoding_helper_spec.rb b/spec/lib/gitlab/encoding_helper_spec.rb index 1a3bf802a07..1482ef7132d 100644 --- a/spec/lib/gitlab/git/encoding_helper_spec.rb +++ b/spec/lib/gitlab/encoding_helper_spec.rb @@ -1,8 +1,8 @@ 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') } +describe Gitlab::EncodingHelper do + let(:ext_class) { Class.new { extend Gitlab::EncodingHelper } } + let(:binary_string) { File.read(Rails.root + "spec/fixtures/dk.png") } describe '#encode!' do [ diff --git a/spec/lib/gitlab/etag_caching/router_spec.rb b/spec/lib/gitlab/etag_caching/router_spec.rb index 46a238b17f4..269798c7c9e 100644 --- a/spec/lib/gitlab/etag_caching/router_spec.rb +++ b/spec/lib/gitlab/etag_caching/router_spec.rb @@ -67,6 +67,17 @@ describe Gitlab::EtagCaching::Router do expect(result.name).to eq 'merge_request_pipelines' end + it 'matches build endpoint' do + env = build_env( + '/my-group/my-project/builds/234.json' + ) + + result = described_class.match(env) + + expect(result).to be_present + expect(result.name).to eq 'project_build' + end + it 'does not match blob with confusing name' do env = build_env( '/my-group/my-project/blob/master/pipelines.json' @@ -77,6 +88,17 @@ describe Gitlab::EtagCaching::Router do expect(result).to be_blank end + it 'matches the environments path' do + env = build_env( + '/my-group/my-project/environments.json' + ) + + result = described_class.match(env) + expect(result).to be_present + + expect(result.name).to eq 'environments' + end + it 'matches pipeline#show endpoint' do env = build_env( '/my-group/my-project/pipelines/2.json' diff --git a/spec/lib/gitlab/git/diff_collection_spec.rb b/spec/lib/gitlab/git/diff_collection_spec.rb index 122c93dcd69..3565e719ad3 100644 --- a/spec/lib/gitlab/git/diff_collection_spec.rb +++ b/spec/lib/gitlab/git/diff_collection_spec.rb @@ -6,18 +6,18 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do iterator, max_files: max_files, max_lines: max_lines, - all_diffs: all_diffs, - no_collapse: no_collapse + limits: limits, + expanded: expanded ) 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 } let(:max_files) { 10 } let(:max_lines) { 100 } - let(:all_diffs) { false } - let(:no_collapse) { true } + let(:limits) { true } + let(:expanded) { true } describe '#to_a' do subject { super().to_a } @@ -64,10 +64,18 @@ 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 } + let(:limits) { false } describe '#overflow?' do subject { super().overflow? } @@ -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 @@ -107,7 +123,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do it { expect(subject.size).to eq(0) } context 'when limiting is disabled' do - let(:all_diffs) { true } + let(:limits) { false } describe '#overflow?' do subject { super().overflow? } @@ -151,7 +167,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do it { expect(subject.size).to eq(10) } context 'when limiting is disabled' do - let(:all_diffs) { true } + let(:limits) { false } describe '#overflow?' do subject { super().overflow? } @@ -191,7 +207,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do it { expect(subject.size).to eq(3) } context 'when limiting is disabled' do - let(:all_diffs) { true } + let(:limits) { false } describe '#overflow?' do subject { super().overflow? } @@ -257,7 +273,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do it { expect(subject.size).to eq(9) } context 'when limiting is disabled' do - let(:all_diffs) { true } + let(:limits) { false } describe '#overflow?' do subject { super().overflow? } @@ -328,7 +344,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do let(:iterator) { [{ diff: 'a' * 20480 }] } context 'when no collapse is set' do - let(:no_collapse) { true } + let(:expanded) { true } it 'yields Diff instances even when they are quite big' do expect { |b| subject.each(&b) }. @@ -347,7 +363,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do end context 'when no collapse is unset' do - let(:no_collapse) { false } + let(:expanded) { false } it 'yields Diff instances even when they are quite big' do expect { |b| subject.each(&b) }. @@ -434,7 +450,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do end context 'when limiting is disabled' do - let(:all_diffs) { true } + let(:limits) { false } it 'yields Diff instances even when they are quite big' do expect { |b| subject.each(&b) }. @@ -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/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb index 4189aaef643..9c2e8a298c6 100644 --- a/spec/lib/gitlab/git/diff_spec.rb +++ b/spec/lib/gitlab/git/diff_spec.rb @@ -85,12 +85,12 @@ EOT # The patch total size is 200, with lines between 21 and 54. # This is a quick-and-dirty way to test this. Ideally, a new patch is # added to the test repo with a size that falls between the real limits. - stub_const("#{described_class}::DIFF_SIZE_LIMIT", 150) - stub_const("#{described_class}::DIFF_COLLAPSE_LIMIT", 100) + stub_const("#{described_class}::SIZE_LIMIT", 150) + stub_const("#{described_class}::COLLAPSE_LIMIT", 100) end it 'prunes the diff as a large diff instead of as a collapsed diff' do - diff = described_class.new(@rugged_diff, collapse: true) + diff = described_class.new(@rugged_diff, expanded: false) expect(diff.diff).to be_empty expect(diff).to be_too_large @@ -110,23 +110,23 @@ EOT end end - context 'using a Gitaly::CommitDiffResponse' do + context 'using a GitalyClient::Diff' do let(:diff) do described_class.new( - Gitaly::CommitDiffResponse.new( + Gitlab::GitalyClient::Diff.new( to_path: ".gitmodules", from_path: ".gitmodules", old_mode: 0100644, new_mode: 0100644, from_id: '357406f3075a57708d0163752905cc1576fceacc', to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0', - raw_chunks: raw_chunks + patch: raw_patch ) ) end context 'with a small diff' do - let(:raw_chunks) { [@raw_diff_hash[:diff]] } + let(:raw_patch) { @raw_diff_hash[:diff] } it 'initializes the diff' do expect(diff.to_hash).to eq(@raw_diff_hash) @@ -138,7 +138,7 @@ EOT end context 'using a diff that is too large' do - let(:raw_chunks) { ['a' * 204800] } + let(:raw_patch) { 'a' * 204800 } it 'prunes the diff' do expect(diff.diff).to be_empty @@ -269,7 +269,7 @@ EOT it 'returns true for a diff that was explicitly marked as being too large' do diff = described_class.new(diff: 'a') - diff.prune_large_diff! + diff.too_large! expect(diff.too_large?).to eq(true) end @@ -291,31 +291,31 @@ EOT it 'returns true for a diff that was explicitly marked as being collapsed' do diff = described_class.new(diff: 'a') - diff.prune_collapsed_diff! + diff.collapse! expect(diff).to be_collapsed end end - describe '#collapsible?' do + describe '#collapsed?' do it 'returns true for a diff that is quite large' do - diff = described_class.new(diff: 'a' * 20480) + diff = described_class.new({ diff: 'a' * 20480 }, expanded: false) - expect(diff).to be_collapsible + expect(diff).to be_collapsed end it 'returns false for a diff that is small enough' do - diff = described_class.new(diff: 'a') + diff = described_class.new({ diff: 'a' }, expanded: false) - expect(diff).not_to be_collapsible + expect(diff).not_to be_collapsed end end - describe '#prune_collapsed_diff!' do + describe '#collapse!' do it 'prunes the diff' do diff = described_class.new(diff: "foo\nbar") - diff.prune_collapsed_diff! + diff.collapse! expect(diff.diff).to eq('') expect(diff.line_count).to eq(0) diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index cb107c6d1f9..26215381cc4 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -1,7 +1,7 @@ require "spec_helper" describe Gitlab::Git::Repository, seed_helper: true do - include Gitlab::Git::EncodingHelper + include Gitlab::EncodingHelper let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH) } @@ -381,6 +381,19 @@ describe Gitlab::Git::Repository, seed_helper: true do } ]) end + + it 'should not break on invalid syntax' do + allow(repository).to receive(:blob_content).and_return(<<-GITMODULES.strip_heredoc) + [submodule "six"] + path = six + url = git://github.com/randx/six.git + + [submodule] + foo = bar + GITMODULES + + expect(submodules).to have_key('six') + end end context 'where repo doesn\'t have submodules' do diff --git a/spec/lib/gitlab/gitaly_client/diff_spec.rb b/spec/lib/gitlab/gitaly_client/diff_spec.rb new file mode 100644 index 00000000000..2960c9a79ad --- /dev/null +++ b/spec/lib/gitlab/gitaly_client/diff_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe Gitlab::GitalyClient::Diff, lib: true do + let(:diff_fields) do + { + to_path: ".gitmodules", + from_path: ".gitmodules", + old_mode: 0100644, + new_mode: 0100644, + from_id: '357406f3075a57708d0163752905cc1576fceacc', + to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0', + patch: 'a' * 100 + } + end + + subject { described_class.new(diff_fields) } + + it { is_expected.to respond_to(:from_path) } + it { is_expected.to respond_to(:to_path) } + it { is_expected.to respond_to(:old_mode) } + it { is_expected.to respond_to(:new_mode) } + it { is_expected.to respond_to(:from_id) } + it { is_expected.to respond_to(:to_id) } + it { is_expected.to respond_to(:patch) } + + describe '#==' do + it { expect(subject).to eq(described_class.new(diff_fields)) } + it { expect(subject).not_to eq(described_class.new(diff_fields.merge(patch: 'a'))) } + end +end diff --git a/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb b/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb new file mode 100644 index 00000000000..07650013052 --- /dev/null +++ b/spec/lib/gitlab/gitaly_client/diff_stitcher_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe Gitlab::GitalyClient::DiffStitcher, lib: true do + describe 'enumeration' do + it 'combines segregated diff messages together' do + diff_1 = OpenStruct.new( + to_path: ".gitmodules", + from_path: ".gitmodules", + old_mode: 0100644, + new_mode: 0100644, + from_id: '357406f3075a57708d0163752905cc1576fceacc', + to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0', + patch: 'a' * 100 + ) + diff_2 = OpenStruct.new( + to_path: ".gitignore", + from_path: ".gitignore", + old_mode: 0100644, + new_mode: 0100644, + from_id: '357406f3075a57708d0163752905cc1576fceacc', + to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0', + patch: 'a' * 200 + ) + diff_3 = OpenStruct.new( + to_path: "README", + from_path: "README", + old_mode: 0100644, + new_mode: 0100644, + from_id: '357406f3075a57708d0163752905cc1576fceacc', + to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0', + patch: 'a' * 100 + ) + + msg_1 = OpenStruct.new(diff_1.to_h.except(:patch)) + msg_1.raw_patch_data = diff_1.patch + msg_1.end_of_patch = true + + msg_2 = OpenStruct.new(diff_2.to_h.except(:patch)) + msg_2.raw_patch_data = diff_2.patch[0..100] + msg_2.end_of_patch = false + + msg_3 = OpenStruct.new(raw_patch_data: diff_2.patch[101..-1], end_of_patch: true) + + msg_4 = OpenStruct.new(diff_3.to_h.except(:patch)) + msg_4.raw_patch_data = diff_3.patch + msg_4.end_of_patch = true + + diff_msgs = [msg_1, msg_2, msg_3, msg_4] + + expected_diffs = [ + Gitlab::GitalyClient::Diff.new(diff_1.to_h), + Gitlab::GitalyClient::Diff.new(diff_2.to_h), + Gitlab::GitalyClient::Diff.new(diff_3.to_h) + ] + + expect(described_class.new(diff_msgs).to_a).to eq(expected_diffs) + end + end +end diff --git a/spec/lib/gitlab/gitaly_client_spec.rb b/spec/lib/gitlab/gitaly_client_spec.rb index 08ee0dff6b2..95ecba67532 100644 --- a/spec/lib/gitlab/gitaly_client_spec.rb +++ b/spec/lib/gitlab/gitaly_client_spec.rb @@ -1,7 +1,10 @@ require 'spec_helper' -describe Gitlab::GitalyClient, lib: true do +# We stub Gitaly in `spec/support/gitaly.rb` for other tests. We don't want +# those stubs while testing the GitalyClient itself. +describe Gitlab::GitalyClient, lib: true, skip_gitaly_mock: true do describe '.stub' do + # Notice that this is referring to gRPC "stubs", not rspec stubs before { described_class.clear_stubs! } context 'when passed a UNIX socket address' do @@ -32,4 +35,81 @@ describe Gitlab::GitalyClient, lib: true do end end end + + describe 'feature_enabled?' do + let(:feature_name) { 'my_feature' } + let(:real_feature_name) { "gitaly_#{feature_name}" } + + context 'when Gitaly is disabled' do + before { allow(described_class).to receive(:enabled?).and_return(false) } + + it 'returns false' do + expect(described_class.feature_enabled?(feature_name)).to be(false) + end + end + + context 'when the feature status is DISABLED' do + let(:feature_status) { Gitlab::GitalyClient::MigrationStatus::DISABLED } + + it 'returns false' do + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false) + end + end + + context 'when the feature_status is OPT_IN' do + let(:feature_status) { Gitlab::GitalyClient::MigrationStatus::OPT_IN } + + context "when the feature flag hasn't been set" do + it 'returns false' do + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false) + end + end + + context "when the feature flag is set to disable" do + before { Feature.get(real_feature_name).disable } + + it 'returns false' do + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false) + end + end + + context "when the feature flag is set to enable" do + before { Feature.get(real_feature_name).enable } + + it 'returns true' do + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(true) + end + end + + context "when the feature flag is set to a percentage of time" do + before { Feature.get(real_feature_name).enable_percentage_of_time(70) } + + it 'bases the result on pseudo-random numbers' do + expect(Random).to receive(:rand).and_return(0.3) + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(true) + + expect(Random).to receive(:rand).and_return(0.8) + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false) + end + end + end + + context 'when the feature_status is OPT_OUT' do + let(:feature_status) { Gitlab::GitalyClient::MigrationStatus::OPT_OUT } + + context "when the feature flag hasn't been set" do + it 'returns true' do + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(true) + end + end + + context "when the feature flag is set to disable" do + before { Feature.get(real_feature_name).disable } + + it 'returns false' do + expect(described_class.feature_enabled?(feature_name, status: feature_status)).to be(false) + end + end + end + end end diff --git a/spec/lib/gitlab/group_hierarchy_spec.rb b/spec/lib/gitlab/group_hierarchy_spec.rb new file mode 100644 index 00000000000..5d0ed1522b3 --- /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 45ccd3d6459..61c10d47434 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/highlight_spec.rb b/spec/lib/gitlab/highlight_spec.rb index e57b3053871..a20cef3b000 100644 --- a/spec/lib/gitlab/highlight_spec.rb +++ b/spec/lib/gitlab/highlight_spec.rb @@ -59,8 +59,6 @@ describe Gitlab::Highlight, lib: true do end describe '#highlight' do - subject { described_class.highlight(file_name, file_content, nowrap: false) } - it 'links dependencies via DependencyLinker' do expect(Gitlab::DependencyLinker).to receive(:link). with('file.name', 'Contents', anything).and_call_original diff --git a/spec/lib/gitlab/i18n_spec.rb b/spec/lib/gitlab/i18n_spec.rb index 52f2614d5ca..a3dbeaa3753 100644 --- a/spec/lib/gitlab/i18n_spec.rb +++ b/spec/lib/gitlab/i18n_spec.rb @@ -1,27 +1,27 @@ require 'spec_helper' -module Gitlab - describe I18n, lib: true do - let(:user) { create(:user, preferred_language: 'es') } +describe Gitlab::I18n, lib: true do + let(:user) { create(:user, preferred_language: 'es') } - describe '.set_locale' do - it 'sets the locale based on current user preferred language' do - Gitlab::I18n.set_locale(user) + describe '.locale=' do + after { described_class.use_default_locale } - expect(FastGettext.locale).to eq('es') - expect(::I18n.locale).to eq(:es) - end + it 'sets the locale based on current user preferred language' do + described_class.locale = user.preferred_language + + expect(FastGettext.locale).to eq('es') + expect(::I18n.locale).to eq(:es) end + end - describe '.reset_locale' do - it 'resets the locale to the default language' do - Gitlab::I18n.set_locale(user) + describe '.use_default_locale' do + it 'resets the locale to the default language' do + described_class.locale = user.preferred_language - Gitlab::I18n.reset_locale + described_class.use_default_locale - expect(FastGettext.locale).to eq('en') - expect(::I18n.locale).to eq(:en) - end + expect(FastGettext.locale).to eq('en') + expect(::I18n.locale).to eq(:en) end end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 34f617e23a5..2e9646286df 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -131,6 +131,7 @@ services: - service_hook hooks: - project +- web_hook_logs protected_branches: - project - merge_access_levels diff --git a/spec/lib/gitlab/import_export/members_mapper_spec.rb b/spec/lib/gitlab/import_export/members_mapper_spec.rb index b9d4e59e770..3e0291c9ae9 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/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 2388aea24d9..dad9d7294ca 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -191,6 +191,7 @@ Ci::Pipeline: - lock_version - auto_canceled_by_id - pipeline_schedule_id +- source CommitStatus: - id - project_id 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 00000000000..1e2a1f8c039 --- /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/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb new file mode 100644 index 00000000000..1eea710c80b --- /dev/null +++ b/spec/lib/gitlab/path_regex_spec.rb @@ -0,0 +1,384 @@ +# coding: utf-8 +require 'spec_helper' + +describe Gitlab::PathRegex, lib: true do + # Pass in a full path to remove the format segment: + # `/ci/lint(.:format)` -> `/ci/lint` + def without_format(path) + path.split('(', 2)[0] + end + + # Pass in a full path and get the last segment before a wildcard + # That's not a parameter + # `/*namespace_id/:project_id/builds/artifacts/*ref_name_and_path` + # -> 'builds/artifacts' + def path_before_wildcard(path) + path = path.gsub(STARTING_WITH_NAMESPACE, "") + path_segments = path.split('/').reject(&:empty?) + wildcard_index = path_segments.index { |segment| parameter?(segment) } + + segments_before_wildcard = path_segments[0..wildcard_index - 1] + + segments_before_wildcard.join('/') + end + + def parameter?(segment) + segment =~ /[*:]/ + end + + # If the path is reserved. Then no conflicting paths can# be created for any + # route using this reserved word. + # + # Both `builds/artifacts` & `build` are covered by reserving the word + # `build` + def wildcards_include?(path) + described_class::PROJECT_WILDCARD_ROUTES.include?(path) || + described_class::PROJECT_WILDCARD_ROUTES.include?(path.split('/').first) + end + + def failure_message(missing_words, constant_name, migration_helper) + missing_words = Array(missing_words) + <<-MSG + Found new routes that could cause conflicts with existing namespaced routes + for groups or projects. + + Add <#{missing_words.join(', ')}> to `Gitlab::PathRegex::#{constant_name} + to make sure no projects or namespaces can be created with those paths. + + To rename any existing records with those paths you can use the + `Gitlab::Database::RenameReservedpathsMigration::<VERSION>.#{migration_helper}` + migration helper. + + Make sure to make a note of the renamed records in the release blog post. + + MSG + end + + let(:all_routes) do + route_set = Rails.application.routes + routes_collection = route_set.routes + routes_array = routes_collection.routes + routes_array.map { |route| route.path.spec.to_s } + end + + let(:routes_without_format) { all_routes.map { |path| without_format(path) } } + + # Routes not starting with `/:` or `/*` + # all routes not starting with a param + let(:routes_not_starting_in_wildcard) { routes_without_format.select { |p| p !~ %r{^/[:*]} } } + + let(:top_level_words) do + routes_not_starting_in_wildcard.map do |route| + route.split('/')[1] + end.compact.uniq + end + + # All routes that start with a namespaced path, that have 1 or more + # path-segments before having another wildcard parameter. + # - Starting with paths: + # - `/*namespace_id/:project_id/` + # - `/*namespace_id/:id/` + # - Followed by one or more path-parts not starting with `:` or `*` + # - Followed by a path-part that includes a wildcard parameter `*` + # At the time of writing these routes match: http://rubular.com/r/Rv2pDE5Dvw + STARTING_WITH_NAMESPACE = %r{^/\*namespace_id/:(project_)?id} + NON_PARAM_PARTS = %r{[^:*][a-z\-_/]*} + ANY_OTHER_PATH_PART = %r{[a-z\-_/:]*} + WILDCARD_SEGMENT = %r{\*} + let(:namespaced_wildcard_routes) do + routes_without_format.select do |p| + p =~ %r{#{STARTING_WITH_NAMESPACE}/#{NON_PARAM_PARTS}/#{ANY_OTHER_PATH_PART}#{WILDCARD_SEGMENT}} + end + end + + # This will return all paths that are used in a namespaced route + # before another wildcard path: + # + # /*namespace_id/:project_id/builds/artifacts/*ref_name_and_path + # /*namespace_id/:project_id/info/lfs/objects/*oid + # /*namespace_id/:project_id/commits/*id + # /*namespace_id/:project_id/builds/:build_id/artifacts/file/*path + # -> ['builds/artifacts', 'info/lfs/objects', 'commits', 'artifacts/file'] + let(:all_wildcard_paths) do + namespaced_wildcard_routes.map do |route| + path_before_wildcard(route) + end.uniq + end + + STARTING_WITH_GROUP = %r{^/groups/\*(group_)?id/} + let(:group_routes) do + routes_without_format.select do |path| + path =~ STARTING_WITH_GROUP + end + end + + let(:paths_after_group_id) do + group_routes.map do |route| + route.gsub(STARTING_WITH_GROUP, '').split('/').first + end.uniq + end + + describe 'TOP_LEVEL_ROUTES' do + it 'includes all the top level namespaces' do + failure_block = lambda do + missing_words = top_level_words - described_class::TOP_LEVEL_ROUTES + failure_message(missing_words, 'TOP_LEVEL_ROUTES', 'rename_root_paths') + end + + expect(described_class::TOP_LEVEL_ROUTES) + .to include(*top_level_words), failure_block + end + end + + describe 'GROUP_ROUTES' do + it "don't contain a second wildcard" do + failure_block = lambda do + missing_words = paths_after_group_id - described_class::GROUP_ROUTES + failure_message(missing_words, 'GROUP_ROUTES', 'rename_child_paths') + end + + expect(described_class::GROUP_ROUTES) + .to include(*paths_after_group_id), failure_block + end + end + + describe 'PROJECT_WILDCARD_ROUTES' do + it 'includes all paths that can be used after a namespace/project path' do + aggregate_failures do + all_wildcard_paths.each do |path| + expect(wildcards_include?(path)) + .to be(true), failure_message(path, 'PROJECT_WILDCARD_ROUTES', 'rename_wildcard_paths') + end + end + end + end + + describe '.root_namespace_path_regex' do + subject { described_class.root_namespace_path_regex } + + it 'rejects top level routes' do + expect(subject).not_to match('admin/') + expect(subject).not_to match('api/') + expect(subject).not_to match('.well-known/') + end + + it 'accepts project wildcard routes' do + expect(subject).to match('blob/') + expect(subject).to match('edit/') + expect(subject).to match('wikis/') + end + + it 'accepts group routes' do + expect(subject).to match('activity/') + expect(subject).to match('group_members/') + expect(subject).to match('subgroups/') + end + + it 'is not case sensitive' do + expect(subject).not_to match('Users/') + end + + it 'does not allow extra slashes' do + expect(subject).not_to match('/blob/') + expect(subject).not_to match('blob//') + end + end + + describe '.full_namespace_path_regex' do + subject { described_class.full_namespace_path_regex } + + context 'at the top level' do + context 'when the final level' do + it 'rejects top level routes' do + expect(subject).not_to match('admin/') + expect(subject).not_to match('api/') + expect(subject).not_to match('.well-known/') + end + + it 'accepts project wildcard routes' do + expect(subject).to match('blob/') + expect(subject).to match('edit/') + expect(subject).to match('wikis/') + end + + it 'accepts group routes' do + expect(subject).to match('activity/') + expect(subject).to match('group_members/') + expect(subject).to match('subgroups/') + end + end + + context 'when more levels follow' do + it 'rejects top level routes' do + expect(subject).not_to match('admin/more/') + expect(subject).not_to match('api/more/') + expect(subject).not_to match('.well-known/more/') + end + + it 'accepts project wildcard routes' do + expect(subject).to match('blob/more/') + expect(subject).to match('edit/more/') + expect(subject).to match('wikis/more/') + expect(subject).to match('environments/folders/') + expect(subject).to match('info/lfs/objects/') + end + + it 'accepts group routes' do + expect(subject).to match('activity/more/') + expect(subject).to match('group_members/more/') + expect(subject).to match('subgroups/more/') + end + end + end + + context 'at the second level' do + context 'when the final level' do + it 'accepts top level routes' do + expect(subject).to match('root/admin/') + expect(subject).to match('root/api/') + expect(subject).to match('root/.well-known/') + end + + it 'rejects project wildcard routes' do + expect(subject).not_to match('root/blob/') + expect(subject).not_to match('root/edit/') + expect(subject).not_to match('root/wikis/') + expect(subject).not_to match('root/environments/folders/') + expect(subject).not_to match('root/info/lfs/objects/') + end + + it 'rejects group routes' do + expect(subject).not_to match('root/activity/') + expect(subject).not_to match('root/group_members/') + expect(subject).not_to match('root/subgroups/') + end + end + + context 'when more levels follow' do + it 'accepts top level routes' do + expect(subject).to match('root/admin/more/') + expect(subject).to match('root/api/more/') + expect(subject).to match('root/.well-known/more/') + end + + it 'rejects project wildcard routes' do + expect(subject).not_to match('root/blob/more/') + expect(subject).not_to match('root/edit/more/') + expect(subject).not_to match('root/wikis/more/') + expect(subject).not_to match('root/environments/folders/more/') + expect(subject).not_to match('root/info/lfs/objects/more/') + end + + it 'rejects group routes' do + expect(subject).not_to match('root/activity/more/') + expect(subject).not_to match('root/group_members/more/') + expect(subject).not_to match('root/subgroups/more/') + end + end + end + + it 'is not case sensitive' do + expect(subject).not_to match('root/Blob/') + end + + it 'does not allow extra slashes' do + expect(subject).not_to match('/root/admin/') + expect(subject).not_to match('root/admin//') + end + end + + describe '.project_path_regex' do + subject { described_class.project_path_regex } + + it 'accepts top level routes' do + expect(subject).to match('admin/') + expect(subject).to match('api/') + expect(subject).to match('.well-known/') + end + + it 'rejects project wildcard routes' do + expect(subject).not_to match('blob/') + expect(subject).not_to match('edit/') + expect(subject).not_to match('wikis/') + expect(subject).not_to match('environments/folders/') + expect(subject).not_to match('info/lfs/objects/') + end + + it 'accepts group routes' do + expect(subject).to match('activity/') + expect(subject).to match('group_members/') + expect(subject).to match('subgroups/') + end + + it 'is not case sensitive' do + expect(subject).not_to match('Blob/') + end + + it 'does not allow extra slashes' do + expect(subject).not_to match('/admin/') + expect(subject).not_to match('admin//') + end + end + + describe '.full_project_path_regex' do + subject { described_class.full_project_path_regex } + + it 'accepts top level routes' do + expect(subject).to match('root/admin/') + expect(subject).to match('root/api/') + expect(subject).to match('root/.well-known/') + end + + it 'rejects project wildcard routes' do + expect(subject).not_to match('root/blob/') + expect(subject).not_to match('root/edit/') + expect(subject).not_to match('root/wikis/') + expect(subject).not_to match('root/environments/folders/') + expect(subject).not_to match('root/info/lfs/objects/') + end + + it 'accepts group routes' do + expect(subject).to match('root/activity/') + expect(subject).to match('root/group_members/') + expect(subject).to match('root/subgroups/') + end + + it 'is not case sensitive' do + expect(subject).not_to match('root/Blob/') + end + + it 'does not allow extra slashes' do + expect(subject).not_to match('/root/admin/') + expect(subject).not_to match('root/admin//') + end + end + + describe '.namespace_format_regex' do + subject { described_class.namespace_format_regex } + + it { is_expected.to match('gitlab-ce') } + it { is_expected.to match('gitlab_git') } + it { is_expected.to match('_underscore.js') } + it { is_expected.to match('100px.com') } + it { is_expected.to match('gitlab.org') } + it { is_expected.not_to match('?gitlab') } + it { is_expected.not_to match('git lab') } + it { is_expected.not_to match('gitlab.git') } + it { is_expected.not_to match('gitlab.org.') } + it { is_expected.not_to match('gitlab.org/') } + it { is_expected.not_to match('/gitlab.org') } + it { is_expected.not_to match('gitlab git') } + end + + describe '.project_path_format_regex' do + subject { described_class.project_path_format_regex } + + it { is_expected.to match('gitlab-ce') } + it { is_expected.to match('gitlab_git') } + it { is_expected.to match('_underscore.js') } + it { is_expected.to match('100px.com') } + it { is_expected.not_to match('?gitlab') } + it { is_expected.not_to match('git lab') } + it { is_expected.not_to match('gitlab.git') } + 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 00000000000..67321f43710 --- /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 1b8690ba613..3d22784909d 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/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index a7d1283acb8..0bee892fe0c 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -2,386 +2,6 @@ require 'spec_helper' describe Gitlab::Regex, lib: true do - # Pass in a full path to remove the format segment: - # `/ci/lint(.:format)` -> `/ci/lint` - def without_format(path) - path.split('(', 2)[0] - end - - # Pass in a full path and get the last segment before a wildcard - # That's not a parameter - # `/*namespace_id/:project_id/builds/artifacts/*ref_name_and_path` - # -> 'builds/artifacts' - def path_before_wildcard(path) - path = path.gsub(STARTING_WITH_NAMESPACE, "") - path_segments = path.split('/').reject(&:empty?) - wildcard_index = path_segments.index { |segment| parameter?(segment) } - - segments_before_wildcard = path_segments[0..wildcard_index - 1] - - segments_before_wildcard.join('/') - end - - def parameter?(segment) - segment =~ /[*:]/ - end - - # If the path is reserved. Then no conflicting paths can# be created for any - # route using this reserved word. - # - # Both `builds/artifacts` & `build` are covered by reserving the word - # `build` - def wildcards_include?(path) - described_class::PROJECT_WILDCARD_ROUTES.include?(path) || - described_class::PROJECT_WILDCARD_ROUTES.include?(path.split('/').first) - end - - def failure_message(missing_words, constant_name, migration_helper) - missing_words = Array(missing_words) - <<-MSG - Found new routes that could cause conflicts with existing namespaced routes - for groups or projects. - - Add <#{missing_words.join(', ')}> to `Gitlab::Regex::#{constant_name} - to make sure no projects or namespaces can be created with those paths. - - To rename any existing records with those paths you can use the - `Gitlab::Database::RenameReservedpathsMigration::<VERSION>.#{migration_helper}` - migration helper. - - Make sure to make a note of the renamed records in the release blog post. - - MSG - end - - let(:all_routes) do - route_set = Rails.application.routes - routes_collection = route_set.routes - routes_array = routes_collection.routes - routes_array.map { |route| route.path.spec.to_s } - end - - let(:routes_without_format) { all_routes.map { |path| without_format(path) } } - - # Routes not starting with `/:` or `/*` - # all routes not starting with a param - let(:routes_not_starting_in_wildcard) { routes_without_format.select { |p| p !~ %r{^/[:*]} } } - - let(:top_level_words) do - routes_not_starting_in_wildcard.map do |route| - route.split('/')[1] - end.compact.uniq - end - - # All routes that start with a namespaced path, that have 1 or more - # path-segments before having another wildcard parameter. - # - Starting with paths: - # - `/*namespace_id/:project_id/` - # - `/*namespace_id/:id/` - # - Followed by one or more path-parts not starting with `:` or `*` - # - Followed by a path-part that includes a wildcard parameter `*` - # At the time of writing these routes match: http://rubular.com/r/Rv2pDE5Dvw - STARTING_WITH_NAMESPACE = %r{^/\*namespace_id/:(project_)?id} - NON_PARAM_PARTS = %r{[^:*][a-z\-_/]*} - ANY_OTHER_PATH_PART = %r{[a-z\-_/:]*} - WILDCARD_SEGMENT = %r{\*} - let(:namespaced_wildcard_routes) do - routes_without_format.select do |p| - p =~ %r{#{STARTING_WITH_NAMESPACE}/#{NON_PARAM_PARTS}/#{ANY_OTHER_PATH_PART}#{WILDCARD_SEGMENT}} - end - end - - # This will return all paths that are used in a namespaced route - # before another wildcard path: - # - # /*namespace_id/:project_id/builds/artifacts/*ref_name_and_path - # /*namespace_id/:project_id/info/lfs/objects/*oid - # /*namespace_id/:project_id/commits/*id - # /*namespace_id/:project_id/builds/:build_id/artifacts/file/*path - # -> ['builds/artifacts', 'info/lfs/objects', 'commits', 'artifacts/file'] - let(:all_wildcard_paths) do - namespaced_wildcard_routes.map do |route| - path_before_wildcard(route) - end.uniq - end - - STARTING_WITH_GROUP = %r{^/groups/\*(group_)?id/} - let(:group_routes) do - routes_without_format.select do |path| - path =~ STARTING_WITH_GROUP - end - end - - let(:paths_after_group_id) do - group_routes.map do |route| - route.gsub(STARTING_WITH_GROUP, '').split('/').first - end.uniq - end - - describe 'TOP_LEVEL_ROUTES' do - it 'includes all the top level namespaces' do - failure_block = lambda do - missing_words = top_level_words - described_class::TOP_LEVEL_ROUTES - failure_message(missing_words, 'TOP_LEVEL_ROUTES', 'rename_root_paths') - end - - expect(described_class::TOP_LEVEL_ROUTES) - .to include(*top_level_words), failure_block - end - end - - describe 'GROUP_ROUTES' do - it "don't contain a second wildcard" do - failure_block = lambda do - missing_words = paths_after_group_id - described_class::GROUP_ROUTES - failure_message(missing_words, 'GROUP_ROUTES', 'rename_child_paths') - end - - expect(described_class::GROUP_ROUTES) - .to include(*paths_after_group_id), failure_block - end - end - - describe 'PROJECT_WILDCARD_ROUTES' do - it 'includes all paths that can be used after a namespace/project path' do - aggregate_failures do - all_wildcard_paths.each do |path| - expect(wildcards_include?(path)) - .to be(true), failure_message(path, 'PROJECT_WILDCARD_ROUTES', 'rename_wildcard_paths') - end - end - end - end - - describe '.root_namespace_path_regex' do - subject { described_class.root_namespace_path_regex } - - it 'rejects top level routes' do - expect(subject).not_to match('admin/') - expect(subject).not_to match('api/') - expect(subject).not_to match('.well-known/') - end - - it 'accepts project wildcard routes' do - expect(subject).to match('blob/') - expect(subject).to match('edit/') - expect(subject).to match('wikis/') - end - - it 'accepts group routes' do - expect(subject).to match('activity/') - expect(subject).to match('group_members/') - expect(subject).to match('subgroups/') - end - - it 'is not case sensitive' do - expect(subject).not_to match('Users/') - end - - it 'does not allow extra slashes' do - expect(subject).not_to match('/blob/') - expect(subject).not_to match('blob//') - end - end - - describe '.full_namespace_path_regex' do - subject { described_class.full_namespace_path_regex } - - context 'at the top level' do - context 'when the final level' do - it 'rejects top level routes' do - expect(subject).not_to match('admin/') - expect(subject).not_to match('api/') - expect(subject).not_to match('.well-known/') - end - - it 'accepts project wildcard routes' do - expect(subject).to match('blob/') - expect(subject).to match('edit/') - expect(subject).to match('wikis/') - end - - it 'accepts group routes' do - expect(subject).to match('activity/') - expect(subject).to match('group_members/') - expect(subject).to match('subgroups/') - end - end - - context 'when more levels follow' do - it 'rejects top level routes' do - expect(subject).not_to match('admin/more/') - expect(subject).not_to match('api/more/') - expect(subject).not_to match('.well-known/more/') - end - - it 'accepts project wildcard routes' do - expect(subject).to match('blob/more/') - expect(subject).to match('edit/more/') - expect(subject).to match('wikis/more/') - expect(subject).to match('environments/folders/') - expect(subject).to match('info/lfs/objects/') - end - - it 'accepts group routes' do - expect(subject).to match('activity/more/') - expect(subject).to match('group_members/more/') - expect(subject).to match('subgroups/more/') - end - end - end - - context 'at the second level' do - context 'when the final level' do - it 'accepts top level routes' do - expect(subject).to match('root/admin/') - expect(subject).to match('root/api/') - expect(subject).to match('root/.well-known/') - end - - it 'rejects project wildcard routes' do - expect(subject).not_to match('root/blob/') - expect(subject).not_to match('root/edit/') - expect(subject).not_to match('root/wikis/') - expect(subject).not_to match('root/environments/folders/') - expect(subject).not_to match('root/info/lfs/objects/') - end - - it 'rejects group routes' do - expect(subject).not_to match('root/activity/') - expect(subject).not_to match('root/group_members/') - expect(subject).not_to match('root/subgroups/') - end - end - - context 'when more levels follow' do - it 'accepts top level routes' do - expect(subject).to match('root/admin/more/') - expect(subject).to match('root/api/more/') - expect(subject).to match('root/.well-known/more/') - end - - it 'rejects project wildcard routes' do - expect(subject).not_to match('root/blob/more/') - expect(subject).not_to match('root/edit/more/') - expect(subject).not_to match('root/wikis/more/') - expect(subject).not_to match('root/environments/folders/more/') - expect(subject).not_to match('root/info/lfs/objects/more/') - end - - it 'rejects group routes' do - expect(subject).not_to match('root/activity/more/') - expect(subject).not_to match('root/group_members/more/') - expect(subject).not_to match('root/subgroups/more/') - end - end - end - - it 'is not case sensitive' do - expect(subject).not_to match('root/Blob/') - end - - it 'does not allow extra slashes' do - expect(subject).not_to match('/root/admin/') - expect(subject).not_to match('root/admin//') - end - end - - describe '.project_path_regex' do - subject { described_class.project_path_regex } - - it 'accepts top level routes' do - expect(subject).to match('admin/') - expect(subject).to match('api/') - expect(subject).to match('.well-known/') - end - - it 'rejects project wildcard routes' do - expect(subject).not_to match('blob/') - expect(subject).not_to match('edit/') - expect(subject).not_to match('wikis/') - expect(subject).not_to match('environments/folders/') - expect(subject).not_to match('info/lfs/objects/') - end - - it 'accepts group routes' do - expect(subject).to match('activity/') - expect(subject).to match('group_members/') - expect(subject).to match('subgroups/') - end - - it 'is not case sensitive' do - expect(subject).not_to match('Blob/') - end - - it 'does not allow extra slashes' do - expect(subject).not_to match('/admin/') - expect(subject).not_to match('admin//') - end - end - - describe '.full_project_path_regex' do - subject { described_class.full_project_path_regex } - - it 'accepts top level routes' do - expect(subject).to match('root/admin/') - expect(subject).to match('root/api/') - expect(subject).to match('root/.well-known/') - end - - it 'rejects project wildcard routes' do - expect(subject).not_to match('root/blob/') - expect(subject).not_to match('root/edit/') - expect(subject).not_to match('root/wikis/') - expect(subject).not_to match('root/environments/folders/') - expect(subject).not_to match('root/info/lfs/objects/') - end - - it 'accepts group routes' do - expect(subject).to match('root/activity/') - expect(subject).to match('root/group_members/') - expect(subject).to match('root/subgroups/') - end - - it 'is not case sensitive' do - expect(subject).not_to match('root/Blob/') - end - - it 'does not allow extra slashes' do - expect(subject).not_to match('/root/admin/') - expect(subject).not_to match('root/admin//') - end - end - - describe '.namespace_regex' do - subject { described_class.namespace_regex } - - it { is_expected.to match('gitlab-ce') } - it { is_expected.to match('gitlab_git') } - it { is_expected.to match('_underscore.js') } - it { is_expected.to match('100px.com') } - it { is_expected.to match('gitlab.org') } - it { is_expected.not_to match('?gitlab') } - it { is_expected.not_to match('git lab') } - it { is_expected.not_to match('gitlab.git') } - it { is_expected.not_to match('gitlab.org.') } - it { is_expected.not_to match('gitlab.org/') } - it { is_expected.not_to match('/gitlab.org') } - it { is_expected.not_to match('gitlab git') } - end - - describe '.project_path_format_regex' do - subject { described_class.project_path_format_regex } - - it { is_expected.to match('gitlab-ce') } - it { is_expected.to match('gitlab_git') } - it { is_expected.to match('_underscore.js') } - it { is_expected.to match('100px.com') } - it { is_expected.not_to match('?gitlab') } - it { is_expected.not_to match('git lab') } - it { is_expected.not_to match('gitlab.git') } - end - describe '.project_name_regex' do subject { described_class.project_name_regex } @@ -412,16 +32,4 @@ describe Gitlab::Regex, lib: true do it { is_expected.not_to match('9foo') } it { is_expected.not_to match('foo-') } end - - describe '.full_namespace_regex' do - subject { described_class.full_namespace_regex } - - it { is_expected.to match('gitlab.org') } - it { is_expected.to match('gitlab.org/gitlab-git') } - it { is_expected.not_to match('gitlab.org.') } - it { is_expected.not_to match('gitlab.org/') } - it { is_expected.not_to match('/gitlab.org') } - it { is_expected.not_to match('gitlab.git') } - it { is_expected.not_to match('gitlab git') } - end 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 00000000000..25146860615 --- /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 fc144a2556a..6bce724a3f6 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/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb index 56772409989..00941aec380 100644 --- a/spec/lib/gitlab/utils_spec.rb +++ b/spec/lib/gitlab/utils_spec.rb @@ -1,5 +1,7 @@ +require 'spec_helper' + describe Gitlab::Utils, lib: true do - delegate :to_boolean, to: :described_class + delegate :to_boolean, :boolean_to_yes_no, to: :described_class describe '.to_boolean' do it 'accepts booleans' do @@ -30,4 +32,11 @@ describe Gitlab::Utils, lib: true do expect(to_boolean(nil)).to be_nil end end + + describe '.boolean_to_yes_no' do + it 'converts booleans to Yes or No' do + expect(boolean_to_yes_no(true)).to eq('Yes') + expect(boolean_to_yes_no(false)).to eq('No') + end + end end diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index fdbb55fc874..b1999409170 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -244,7 +244,7 @@ describe Gitlab::Workhorse, lib: true do context "when git_receive_pack action is passed" do let(:action) { 'git_receive_pack' } - it { expect(subject).not_to include(gitaly_params) } + it { expect(subject).to include(gitaly_params) } end context "when info_refs action is passed" do diff --git a/spec/lib/system_check/simple_executor_spec.rb b/spec/lib/system_check/simple_executor_spec.rb new file mode 100644 index 00000000000..a5c6170cd7d --- /dev/null +++ b/spec/lib/system_check/simple_executor_spec.rb @@ -0,0 +1,223 @@ +require 'spec_helper' +require 'rake_helper' + +describe SystemCheck::SimpleExecutor, lib: true do + class SimpleCheck < SystemCheck::BaseCheck + set_name 'my simple check' + + def check? + true + end + end + + class OtherCheck < SystemCheck::BaseCheck + set_name 'other check' + + def check? + false + end + + def show_error + $stdout.puts 'this is an error text' + end + end + + class SkipCheck < SystemCheck::BaseCheck + set_name 'skip check' + set_skip_reason 'this is a skip reason' + + def skip? + true + end + + def check? + raise 'should not execute this' + end + end + + class MultiCheck < SystemCheck::BaseCheck + set_name 'multi check' + + def multi_check + $stdout.puts 'this is a multi output check' + end + + def check? + raise 'should not execute this' + end + end + + class SkipMultiCheck < SystemCheck::BaseCheck + set_name 'skip multi check' + + def skip? + true + end + + def multi_check + raise 'should not execute this' + end + end + + class RepairCheck < SystemCheck::BaseCheck + set_name 'repair check' + + def check? + false + end + + def repair! + true + end + + def show_error + $stdout.puts 'this is an error message' + end + end + + describe '#component' do + it 'returns stored component name' do + expect(subject.component).to eq('Test') + end + end + + describe '#checks' do + before do + subject << SimpleCheck + end + + it 'returns a set of classes' do + expect(subject.checks).to include(SimpleCheck) + end + end + + describe '#<<' do + before do + subject << SimpleCheck + end + + it 'appends a new check to the Set' do + subject << OtherCheck + stored_checks = subject.checks.to_a + + expect(stored_checks.first).to eq(SimpleCheck) + expect(stored_checks.last).to eq(OtherCheck) + end + + it 'inserts unique itens only' do + subject << SimpleCheck + + expect(subject.checks.size).to eq(1) + end + end + + subject { described_class.new('Test') } + + describe '#execute' do + before do + silence_output + + subject << SimpleCheck + subject << OtherCheck + end + + it 'runs included checks' do + expect(subject).to receive(:run_check).with(SimpleCheck) + expect(subject).to receive(:run_check).with(OtherCheck) + + subject.execute + end + end + + describe '#run_check' do + it 'prints check name' do + expect(SimpleCheck).to receive(:display_name).and_call_original + expect { subject.run_check(SimpleCheck) }.to output(/my simple check/).to_stdout + end + + context 'when check pass' do + it 'prints yes' do + expect_any_instance_of(SimpleCheck).to receive(:check?).and_call_original + expect { subject.run_check(SimpleCheck) }.to output(/ \.\.\. yes/).to_stdout + end + end + + context 'when check fails' do + it 'prints no' do + expect_any_instance_of(OtherCheck).to receive(:check?).and_call_original + expect { subject.run_check(OtherCheck) }.to output(/ \.\.\. no/).to_stdout + end + + it 'displays error message from #show_error' do + expect_any_instance_of(OtherCheck).to receive(:show_error).and_call_original + expect { subject.run_check(OtherCheck) }.to output(/this is an error text/).to_stdout + end + + context 'when check implements #repair!' do + it 'executes #repair!' do + expect_any_instance_of(RepairCheck).to receive(:repair!) + + subject.run_check(RepairCheck) + end + + context 'when repair succeeds' do + it 'does not execute #show_error' do + expect_any_instance_of(RepairCheck).to receive(:repair!).and_call_original + expect_any_instance_of(RepairCheck).not_to receive(:show_error) + + subject.run_check(RepairCheck) + end + end + + context 'when repair fails' do + it 'does not execute #show_error' do + expect_any_instance_of(RepairCheck).to receive(:repair!) { false } + expect_any_instance_of(RepairCheck).to receive(:show_error) + + subject.run_check(RepairCheck) + end + end + end + end + + context 'when check implements skip?' do + it 'executes #skip? method' do + expect_any_instance_of(SkipCheck).to receive(:skip?).and_call_original + + subject.run_check(SkipCheck) + end + + it 'displays #skip_reason' do + expect { subject.run_check(SkipCheck) }.to output(/this is a skip reason/).to_stdout + end + + it 'does not execute #check when #skip? is true' do + expect_any_instance_of(SkipCheck).not_to receive(:check?) + + subject.run_check(SkipCheck) + end + end + + context 'when implements a #multi_check' do + it 'executes #multi_check method' do + expect_any_instance_of(MultiCheck).to receive(:multi_check) + + subject.run_check(MultiCheck) + end + + it 'does not execute #check method' do + expect_any_instance_of(MultiCheck).not_to receive(:check) + + subject.run_check(MultiCheck) + end + + context 'when check implements #skip?' do + it 'executes #skip? method' do + expect_any_instance_of(SkipMultiCheck).to receive(:skip?).and_call_original + + subject.run_check(SkipMultiCheck) + end + end + end + end +end diff --git a/spec/lib/system_check_spec.rb b/spec/lib/system_check_spec.rb new file mode 100644 index 00000000000..23d9beddb08 --- /dev/null +++ b/spec/lib/system_check_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' +require 'rake_helper' + +describe SystemCheck, lib: true do + class SimpleCheck < SystemCheck::BaseCheck + def check? + true + end + end + + class OtherCheck < SystemCheck::BaseCheck + def check? + false + end + end + + before do + silence_output + end + + describe '.run' do + subject { SystemCheck } + + it 'detects execution of SimpleCheck' do + is_expected.to execute_check(SimpleCheck) + + subject.run('Test', [SimpleCheck]) + end + + it 'detects exclusion of OtherCheck in execution' do + is_expected.not_to execute_check(OtherCheck) + + subject.run('Test', [SimpleCheck]) + end + end +end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 1e6260270fe..ec6f6c42eac 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -128,6 +128,15 @@ describe Notify do is_expected.to have_body_text(namespace_project_issue_path(project.namespace, project, issue)) end end + + context 'with a preferred language' do + before { Gitlab::I18n.locale = :es } + after { Gitlab::I18n.use_default_locale } + + it 'always generates the email using the default language' do + is_expected.to have_body_text('foo, bar, and baz') + end + end end describe 'status changed' do diff --git a/spec/migrations/fill_authorized_projects_spec.rb b/spec/migrations/fill_authorized_projects_spec.rb deleted file mode 100644 index 99dc4195818..00000000000 --- 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/migrate_old_artifacts_spec.rb b/spec/migrations/migrate_old_artifacts_spec.rb new file mode 100644 index 00000000000..50f4bbda001 --- /dev/null +++ b/spec/migrations/migrate_old_artifacts_spec.rb @@ -0,0 +1,117 @@ +# encoding: utf-8 + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170523083112_migrate_old_artifacts.rb') + +describe MigrateOldArtifacts do + let(:migration) { described_class.new } + let!(:directory) { Dir.mktmpdir } + + before do + allow(Gitlab.config.artifacts).to receive(:path).and_return(directory) + end + + after do + FileUtils.remove_entry_secure(directory) + end + + context 'with migratable data' do + let(:project1) { create(:empty_project, ci_id: 2) } + let(:project2) { create(:empty_project, ci_id: 3) } + let(:project3) { create(:empty_project) } + + let(:pipeline1) { create(:ci_empty_pipeline, project: project1) } + let(:pipeline2) { create(:ci_empty_pipeline, project: project2) } + let(:pipeline3) { create(:ci_empty_pipeline, project: project3) } + + let!(:build_with_legacy_artifacts) { create(:ci_build, pipeline: pipeline1) } + let!(:build_without_artifacts) { create(:ci_build, pipeline: pipeline1) } + let!(:build2) { create(:ci_build, :artifacts, pipeline: pipeline2) } + let!(:build3) { create(:ci_build, :artifacts, pipeline: pipeline3) } + + before do + store_artifacts_in_legacy_path(build_with_legacy_artifacts) + end + + it "legacy artifacts are not accessible" do + expect(build_with_legacy_artifacts.artifacts?).to be_falsey + end + + it "legacy artifacts are set" do + expect(build_with_legacy_artifacts.artifacts_file_identifier).not_to be_nil + end + + describe '#min_id' do + subject { migration.send(:min_id) } + + it 'returns the newest build for which ci_id is not defined' do + is_expected.to eq(build3.id) + end + end + + describe '#builds_with_artifacts' do + subject { migration.send(:builds_with_artifacts).map(&:id) } + + it 'returns a list of builds that has artifacts and could be migrated' do + is_expected.to contain_exactly(build_with_legacy_artifacts.id, build2.id) + end + end + + describe '#up' do + context 'when migrating artifacts' do + before do + migration.up + end + + it 'all files do have artifacts' do + Ci::Build.with_artifacts do |build| + expect(build).to have_artifacts + end + end + + it 'artifacts are no longer present on legacy path' do + expect(File.exist?(legacy_path(build_with_legacy_artifacts))).to eq(false) + end + end + + context 'when there are aritfacts in old and new directory' do + before do + store_artifacts_in_legacy_path(build2) + + migration.up + end + + it 'does not move old files' do + expect(File.exist?(legacy_path(build2))).to eq(true) + end + end + end + + private + + def store_artifacts_in_legacy_path(build) + FileUtils.mkdir_p(legacy_path(build)) + + FileUtils.copy( + Rails.root.join('spec/fixtures/ci_build_artifacts.zip'), + File.join(legacy_path(build), "ci_build_artifacts.zip")) + + FileUtils.copy( + Rails.root.join('spec/fixtures/ci_build_artifacts_metadata.gz'), + File.join(legacy_path(build), "ci_build_artifacts_metadata.gz")) + + build.update_columns( + artifacts_file: 'ci_build_artifacts.zip', + artifacts_metadata: 'ci_build_artifacts_metadata.gz') + + build.reload + end + + def legacy_path(build) + File.join(directory, + build.created_at.utc.strftime('%Y_%m'), + build.project.ci_id.to_s, + build.id.to_s) + 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 00000000000..175bf1876b2 --- /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 index 3742b4dafe5..3742b4dafe5 100644 --- a/spec/migrations/update_retried_for_ci_builds_spec.rb +++ b/spec/migrations/update_retried_for_ci_build_spec.rb diff --git a/spec/models/blob_viewer/base_spec.rb b/spec/models/blob_viewer/base_spec.rb index 92fbf64a6b7..d56379eb59d 100644 --- a/spec/models/blob_viewer/base_spec.rb +++ b/spec/models/blob_viewer/base_spec.rb @@ -11,8 +11,8 @@ describe BlobViewer::Base, model: true do self.extensions = %w(pdf) self.binary = true - self.overridable_max_size = 1.megabyte - self.max_size = 5.megabytes + self.collapse_limit = 1.megabyte + self.size_limit = 5.megabytes end end @@ -69,77 +69,49 @@ describe BlobViewer::Base, model: true do end end - describe '#exceeds_overridable_max_size?' do - context 'when the blob size is larger than the overridable max size' do + describe '#collapsed?' do + context 'when the blob size is larger than the collapse limit' do let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } it 'returns true' do - expect(viewer.exceeds_overridable_max_size?).to be_truthy + expect(viewer.collapsed?).to be_truthy end end - context 'when the blob size is smaller than the overridable max size' do + context 'when the blob size is smaller than the collapse limit' do let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } it 'returns false' do - expect(viewer.exceeds_overridable_max_size?).to be_falsey + expect(viewer.collapsed?).to be_falsey end end end - describe '#exceeds_max_size?' do - context 'when the blob size is larger than the max size' do + describe '#too_large?' do + context 'when the blob size is larger than the size limit' do let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } it 'returns true' do - expect(viewer.exceeds_max_size?).to be_truthy + expect(viewer.too_large?).to be_truthy end end - context 'when the blob size is smaller than the max size' do + context 'when the blob size is smaller than the size limit' do let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } it 'returns false' do - expect(viewer.exceeds_max_size?).to be_falsey - end - end - end - - describe '#can_override_max_size?' do - context 'when the blob size is larger than the overridable max size' do - context 'when the blob size is larger than the max size' do - let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } - - it 'returns false' do - expect(viewer.can_override_max_size?).to be_falsey - end - end - - context 'when the blob size is smaller than the max size' do - let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } - - it 'returns true' do - expect(viewer.can_override_max_size?).to be_truthy - end - end - end - - context 'when the blob size is smaller than the overridable max size' do - let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } - - it 'returns false' do - expect(viewer.can_override_max_size?).to be_falsey + expect(viewer.too_large?).to be_falsey end end end describe '#render_error' do - context 'when the max size is overridden' do + context 'when expanded' do before do - viewer.override_max_size = true + viewer.expanded = true end - context 'when the blob size is larger than the max size' do + context 'when the blob size is larger than the size limit' do let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } it 'returns :too_large' do @@ -147,7 +119,7 @@ describe BlobViewer::Base, model: true do end end - context 'when the blob size is smaller than the max size' do + context 'when the blob size is smaller than the size limit' do let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } it 'returns nil' do @@ -156,16 +128,16 @@ describe BlobViewer::Base, model: true do end end - context 'when the max size is not overridden' do - context 'when the blob size is larger than the overridable max size' do + context 'when not expanded' do + context 'when the blob size is larger than the collapse limit' do let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } - it 'returns :too_large' do - expect(viewer.render_error).to eq(:too_large) + it 'returns :collapsed' do + expect(viewer.render_error).to eq(:collapsed) end end - context 'when the blob size is smaller than the overridable max size' do + context 'when the blob size is smaller than the collapse limit' do let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } it 'returns nil' do diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index e971b4bc3f9..b0716e04d3d 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -427,6 +427,42 @@ describe Ci::Build, :models do end end + describe '#environment_url' do + subject { job.environment_url } + + context 'when yaml environment uses $CI_COMMIT_REF_NAME' do + let(:job) do + create(:ci_build, + ref: 'master', + options: { environment: { url: 'http://review/$CI_COMMIT_REF_NAME' } }) + end + + it { is_expected.to eq('http://review/master') } + end + + context 'when yaml environment uses yaml_variables containing symbol keys' do + let(:job) do + create(:ci_build, + yaml_variables: [{ key: :APP_HOST, value: 'host' }], + options: { environment: { url: 'http://review/$APP_HOST' } }) + end + + it { is_expected.to eq('http://review/host') } + end + + context 'when yaml environment does not have url' do + let(:job) { create(:ci_build, environment: 'staging') } + + let!(:environment) do + create(:environment, project: job.project, name: job.environment) + end + + it 'returns the external_url from persisted environment' do + is_expected.to eq(environment.external_url) + end + end + end + describe '#starts_environment?' do subject { build.starts_environment? } @@ -918,6 +954,10 @@ describe Ci::Build, :models do it { is_expected.to eq(environment) } end + + context 'when there is no environment' do + it { is_expected.to be_nil } + end end describe '#play' do @@ -1139,6 +1179,7 @@ describe Ci::Build, :models do { key: 'CI_PROJECT_ID', value: project.id.to_s, public: true }, { key: 'CI_PROJECT_NAME', value: project.path, public: true }, { key: 'CI_PROJECT_PATH', value: project.full_path, public: true }, + { key: 'CI_PROJECT_PATH_SLUG', value: project.full_path.parameterize, public: true }, { key: 'CI_PROJECT_NAMESPACE', value: project.namespace.full_path, public: true }, { key: 'CI_PROJECT_URL', value: project.web_url, public: true }, { key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true }, @@ -1176,11 +1217,6 @@ describe Ci::Build, :models do end context 'when build has an environment' do - before do - build.update(environment: 'production') - create(:environment, project: build.project, name: 'production', slug: 'prod-slug') - end - let(:environment_variables) do [ { key: 'CI_ENVIRONMENT_NAME', value: 'production', public: true }, @@ -1188,7 +1224,56 @@ describe Ci::Build, :models do ] end - it { environment_variables.each { |v| is_expected.to include(v) } } + let!(:environment) do + create(:environment, + project: build.project, + name: 'production', + slug: 'prod-slug', + external_url: '') + end + + before do + build.update(environment: 'production') + end + + shared_examples 'containing environment variables' do + it { environment_variables.each { |v| is_expected.to include(v) } } + end + + context 'when no URL was set' do + it_behaves_like 'containing environment variables' + + it 'does not have CI_ENVIRONMENT_URL' do + keys = subject.map { |var| var[:key] } + + expect(keys).not_to include('CI_ENVIRONMENT_URL') + end + end + + context 'when an URL was set' do + let(:url) { 'http://host/test' } + + before do + environment_variables << + { key: 'CI_ENVIRONMENT_URL', value: url, public: true } + end + + context 'when the URL was set from the job' do + before do + build.update(options: { environment: { url: 'http://host/$CI_JOB_NAME' } }) + end + + it_behaves_like 'containing environment variables' + end + + context 'when the URL was not set from the job, but environment' do + before do + environment.update(external_url: url) + end + + it_behaves_like 'containing environment variables' + end + end end context 'when build started manually' do @@ -1215,16 +1300,49 @@ describe Ci::Build, :models do it { is_expected.to include(tag_variable) } end - context 'when secure variable is defined' do - let(:secure_variable) do + context 'when secret variable is defined' do + let(:secret_variable) do { key: 'SECRET_KEY', value: 'secret_value', public: false } end before do - build.project.variables << Ci::Variable.new(key: 'SECRET_KEY', value: 'secret_value') + create(:ci_variable, + secret_variable.slice(:key, :value).merge(project: project)) + end + + it { is_expected.to include(secret_variable) } + end + + context 'when protected variable is defined' do + let(:protected_variable) do + { key: 'PROTECTED_KEY', value: 'protected_value', public: false } + end + + before do + create(:ci_variable, + :protected, + protected_variable.slice(:key, :value).merge(project: project)) + end + + context 'when the branch is protected' do + before do + create(:protected_branch, project: build.project, name: build.ref) + end + + it { is_expected.to include(protected_variable) } end - it { is_expected.to include(secure_variable) } + context 'when the tag is protected' do + before do + create(:protected_tag, project: build.project, name: build.ref) + end + + it { is_expected.to include(protected_variable) } + end + + context 'when the ref is not protected' do + it { is_expected.not_to include(protected_variable) } + end end context 'when build is for triggers' do @@ -1346,15 +1464,30 @@ describe Ci::Build, :models do end context 'returns variables in valid order' do + let(:build_pre_var) { { key: 'build', value: 'value' } } + let(:project_pre_var) { { key: 'project', value: 'value' } } + let(:pipeline_pre_var) { { key: 'pipeline', value: 'value' } } + let(:build_yaml_var) { { key: 'yaml', value: 'value' } } + before do - allow(build).to receive(:predefined_variables) { ['predefined'] } - allow(project).to receive(:predefined_variables) { ['project'] } - allow(pipeline).to receive(:predefined_variables) { ['pipeline'] } - allow(build).to receive(:yaml_variables) { ['yaml'] } - allow(project).to receive(:secret_variables) { ['secret'] } + allow(build).to receive(:predefined_variables) { [build_pre_var] } + allow(project).to receive(:predefined_variables) { [project_pre_var] } + allow(pipeline).to receive(:predefined_variables) { [pipeline_pre_var] } + allow(build).to receive(:yaml_variables) { [build_yaml_var] } + + allow(project).to receive(:secret_variables_for).with(build.ref) do + [create(:ci_variable, key: 'secret', value: 'value')] + end end - it { is_expected.to eq(%w[predefined project pipeline yaml secret]) } + it do + is_expected.to eq( + [build_pre_var, + project_pre_var, + pipeline_pre_var, + build_yaml_var, + { key: 'secret', value: 'value', public: false }]) + end end end diff --git a/spec/models/ci/pipeline_schedule_spec.rb b/spec/models/ci/pipeline_schedule_spec.rb index 822b98c5f6c..b00e7a73571 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/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 56b24ce62f3..ae1b01b76ab 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -21,13 +21,35 @@ describe Ci::Pipeline, models: true do it { is_expected.to have_many(:auto_canceled_pipelines) } it { is_expected.to have_many(:auto_canceled_jobs) } - it { is_expected.to validate_presence_of :sha } - it { is_expected.to validate_presence_of :status } + it { is_expected.to validate_presence_of(:sha) } + it { is_expected.to validate_presence_of(:status) } it { is_expected.to respond_to :git_author_name } it { is_expected.to respond_to :git_author_email } it { is_expected.to respond_to :short_sha } + describe '#source' do + context 'when creating new pipeline' do + let(:pipeline) do + build(:ci_empty_pipeline, status: :created, project: project, source: nil) + end + + it "prevents from creating an object" do + expect(pipeline).not_to be_valid + end + end + + context 'when updating existing pipeline' do + before do + pipeline.update_attribute(:source, nil) + end + + it "object is valid" do + expect(pipeline).to be_valid + end + end + end + describe '#block' do it 'changes pipeline status to manual' do expect(pipeline.block).to be true @@ -965,7 +987,7 @@ describe Ci::Pipeline, models: true do end before do - ProjectWebHookWorker.drain + WebHookWorker.drain end context 'with pipeline hooks enabled' do diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb index fe8c52d5353..077b10227d7 100644 --- a/spec/models/ci/variable_spec.rb +++ b/spec/models/ci/variable_spec.rb @@ -12,11 +12,33 @@ describe Ci::Variable, models: true do it { is_expected.not_to allow_value('foo bar').for(:key) } it { is_expected.not_to allow_value('foo/bar').for(:key) } - before :each do - subject.value = secret_value + describe '.unprotected' do + subject { described_class.unprotected } + + context 'when variable is protected' do + before do + create(:ci_variable, :protected) + end + + it 'returns nothing' do + is_expected.to be_empty + end + end + + context 'when variable is not protected' do + let(:variable) { create(:ci_variable, protected: false) } + + it 'returns the variable' do + is_expected.to contain_exactly(variable) + end + end end describe '#value' do + before do + subject.value = secret_value + end + it 'stores the encrypted value' do expect(subject.encrypted_value).not_to be_nil end @@ -36,4 +58,11 @@ describe Ci::Variable, models: true do to raise_error(OpenSSL::Cipher::CipherError, 'bad decrypt') end end + + describe '#to_runner_variable' do + it 'returns a hash for the runner' do + expect(subject.to_runner_variable) + .to eq(key: subject.key, value: subject.value, public: false) + end + end end diff --git a/spec/models/concerns/editable_spec.rb b/spec/models/concerns/editable_spec.rb new file mode 100644 index 00000000000..cd73af3b480 --- /dev/null +++ b/spec/models/concerns/editable_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe Editable do + describe '#is_edited?' do + let(:issue) { create(:issue, last_edited_at: nil) } + let(:edited_issue) { create(:issue, created_at: 3.days.ago, last_edited_at: 2.days.ago) } + + it { expect(issue.is_edited?).to eq(false) } + it { expect(edited_issue.is_edited?).to eq(true) } + end +end diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb index 49a4132f763..0e10d91836d 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/deployment_spec.rb b/spec/models/deployment_spec.rb index 4bda7d4314a..6f0d2db23c7 100644 --- a/spec/models/deployment_spec.rb +++ b/spec/models/deployment_spec.rb @@ -16,6 +16,19 @@ describe Deployment, models: true do it { is_expected.to validate_presence_of(:ref) } it { is_expected.to validate_presence_of(:sha) } + describe 'after_create callbacks' do + let(:environment) { create(:environment) } + let(:store) { Gitlab::EtagCaching::Store.new } + + it 'invalidates the environment etag cache' do + old_value = store.get(environment.etag_cache_key) + + create(:deployment, environment: environment) + + expect(store.get(environment.etag_cache_key)).not_to eq(old_value) + end + end + describe '#includes_commit?' do let(:project) { create(:project, :repository) } let(:environment) { create(:environment, project: project) } diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index ab4c51a87b0..297c2108dc2 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -145,7 +145,7 @@ describe DiffNote, models: true do context "when the merge request's diff refs don't match that of the diff note" do before do - allow(subject.noteable).to receive(:diff_sha_refs).and_return(commit.diff_refs) + allow(subject.noteable).to receive(:diff_refs).and_return(commit.diff_refs) end it "returns false" do @@ -160,12 +160,6 @@ describe DiffNote, models: true do context "when noteable is a commit" do let(:diff_note) { create(:diff_note_on_commit, project: project, position: position) } - it "doesn't use the DiffPositionUpdateService" do - expect(Notes::DiffPositionUpdateService).not_to receive(:new) - - diff_note - end - it "doesn't update the position" do diff_note @@ -178,12 +172,6 @@ describe DiffNote, models: true do let(:diff_note) { create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) } context "when the note is active" do - it "doesn't use the DiffPositionUpdateService" do - expect(Notes::DiffPositionUpdateService).not_to receive(:new) - - diff_note - end - it "doesn't update the position" do diff_note @@ -194,21 +182,14 @@ describe DiffNote, models: true do context "when the note is outdated" do before do - allow(merge_request).to receive(:diff_sha_refs).and_return(commit.diff_refs) + allow(merge_request).to receive(:diff_refs).and_return(commit.diff_refs) end - it "uses the DiffPositionUpdateService" do - service = instance_double("Notes::DiffPositionUpdateService") - expect(Notes::DiffPositionUpdateService).to receive(:new).with( - project, - nil, - old_diff_refs: position.diff_refs, - new_diff_refs: commit.diff_refs, - paths: [path] - ).and_return(service) - expect(service).to receive(:execute) - + it "updates the position" do diff_note + + expect(diff_note.original_position).to eq(position) + expect(diff_note.position).not_to eq(position) end end end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index 12519de8636..fe69c8e351d 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Environment, models: true do - let(:project) { create(:empty_project) } + set(:project) { create(:empty_project) } subject(:environment) { create(:environment, project: project) } it { is_expected.to belong_to(:project) } @@ -34,6 +34,26 @@ describe Environment, models: true do end end + describe 'state machine' do + it 'invalidates the cache after a change' do + expect(environment).to receive(:expire_etag_cache) + + environment.stop + end + end + + describe '#expire_etag_cache' do + let(:store) { Gitlab::EtagCaching::Store.new } + + it 'changes the cached value' do + old_value = store.get(environment.etag_cache_key) + + environment.stop + + expect(store.get(environment.etag_cache_key)).not_to eq(old_value) + end + end + describe '#nullify_external_url' do it 'replaces a blank url with nil' do env = build(:environment, external_url: "") @@ -227,7 +247,10 @@ describe Environment, models: true do context 'when user is allowed to stop environment' do before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) end context 'when action did not yet finish' do diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 6ca1eb0374d..316bf153660 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/hooks/service_hook_spec.rb b/spec/models/hooks/service_hook_spec.rb index 1a83c836652..57454d2a773 100644 --- a/spec/models/hooks/service_hook_spec.rb +++ b/spec/models/hooks/service_hook_spec.rb @@ -1,36 +1,19 @@ -require "spec_helper" +require 'spec_helper' describe ServiceHook, models: true do - describe "Associations" do + describe 'associations' do it { is_expected.to belong_to :service } end - describe "execute" do - before(:each) do - @service_hook = create(:service_hook) - @data = { project_id: 1, data: {} } + describe 'execute' do + let(:hook) { build(:service_hook) } + let(:data) { { key: 'value' } } - WebMock.stub_request(:post, @service_hook.url) - end - - it "POSTs to the webhook URL" do - @service_hook.execute(@data) - expect(WebMock).to have_requested(:post, @service_hook.url).with( - headers: { 'Content-Type' => 'application/json', 'X-Gitlab-Event' => 'Service Hook' } - ).once - end - - it "POSTs the data as JSON" do - @service_hook.execute(@data) - expect(WebMock).to have_requested(:post, @service_hook.url).with( - headers: { 'Content-Type' => 'application/json', 'X-Gitlab-Event' => 'Service Hook' } - ).once - end - - it "catches exceptions" do - expect(WebHook).to receive(:post).and_raise("Some HTTP Post error") + it '#execute' do + expect(WebHookService).to receive(:new).with(hook, data, 'service_hook').and_call_original + expect_any_instance_of(WebHookService).to receive(:execute) - expect { @service_hook.execute(@data) }.to raise_error(RuntimeError) + hook.execute(data) end end end diff --git a/spec/models/hooks/system_hook_spec.rb b/spec/models/hooks/system_hook_spec.rb index 4340170888d..0d2b622132e 100644 --- a/spec/models/hooks/system_hook_spec.rb +++ b/spec/models/hooks/system_hook_spec.rb @@ -126,4 +126,26 @@ describe SystemHook, models: true do expect(SystemHook.repository_update_hooks).to eq([hook]) end end + + describe 'execute WebHookService' do + let(:hook) { build(:system_hook) } + let(:data) { { key: 'value' } } + let(:hook_name) { 'system_hook' } + + before do + expect(WebHookService).to receive(:new).with(hook, data, hook_name).and_call_original + end + + it '#execute' do + expect_any_instance_of(WebHookService).to receive(:execute) + + hook.execute(data, hook_name) + end + + it '#async_execute' do + expect_any_instance_of(WebHookService).to receive(:async_execute) + + hook.async_execute(data, hook_name) + end + end end diff --git a/spec/models/hooks/web_hook_log_spec.rb b/spec/models/hooks/web_hook_log_spec.rb new file mode 100644 index 00000000000..c649cf3b589 --- /dev/null +++ b/spec/models/hooks/web_hook_log_spec.rb @@ -0,0 +1,30 @@ +require 'rails_helper' + +describe WebHookLog, models: true do + it { is_expected.to belong_to(:web_hook) } + + it { is_expected.to serialize(:request_headers).as(Hash) } + it { is_expected.to serialize(:request_data).as(Hash) } + it { is_expected.to serialize(:response_headers).as(Hash) } + + it { is_expected.to validate_presence_of(:web_hook) } + + describe '#success?' do + let(:web_hook_log) { build(:web_hook_log, response_status: status) } + + describe '2xx' do + let(:status) { '200' } + it { expect(web_hook_log.success?).to be_truthy } + end + + describe 'not 2xx' do + let(:status) { '500' } + it { expect(web_hook_log.success?).to be_falsey } + end + + describe 'internal erorr' do + let(:status) { 'internal error' } + it { expect(web_hook_log.success?).to be_falsey } + end + end +end diff --git a/spec/models/hooks/web_hook_spec.rb b/spec/models/hooks/web_hook_spec.rb index 9d4db1bfb52..53157c24477 100644 --- a/spec/models/hooks/web_hook_spec.rb +++ b/spec/models/hooks/web_hook_spec.rb @@ -1,89 +1,54 @@ require 'spec_helper' describe WebHook, models: true do - describe "Validations" do + let(:hook) { build(:project_hook) } + + describe 'associations' do + it { is_expected.to have_many(:web_hook_logs).dependent(:destroy) } + end + + describe 'validations' do it { is_expected.to validate_presence_of(:url) } describe 'url' do - it { is_expected.to allow_value("http://example.com").for(:url) } - it { is_expected.to allow_value("https://example.com").for(:url) } - it { is_expected.to allow_value(" https://example.com ").for(:url) } - it { is_expected.to allow_value("http://test.com/api").for(:url) } - it { is_expected.to allow_value("http://test.com/api?key=abc").for(:url) } - it { is_expected.to allow_value("http://test.com/api?key=abc&type=def").for(:url) } + it { is_expected.to allow_value('http://example.com').for(:url) } + it { is_expected.to allow_value('https://example.com').for(:url) } + it { is_expected.to allow_value(' https://example.com ').for(:url) } + it { is_expected.to allow_value('http://test.com/api').for(:url) } + it { is_expected.to allow_value('http://test.com/api?key=abc').for(:url) } + it { is_expected.to allow_value('http://test.com/api?key=abc&type=def').for(:url) } - it { is_expected.not_to allow_value("example.com").for(:url) } - it { is_expected.not_to allow_value("ftp://example.com").for(:url) } - it { is_expected.not_to allow_value("herp-and-derp").for(:url) } + it { is_expected.not_to allow_value('example.com').for(:url) } + it { is_expected.not_to allow_value('ftp://example.com').for(:url) } + it { is_expected.not_to allow_value('herp-and-derp').for(:url) } it 'strips :url before saving it' do - hook = create(:project_hook, url: ' https://example.com ') + hook.url = ' https://example.com ' + hook.save expect(hook.url).to eq('https://example.com') end end end - describe "execute" do - let(:project) { create(:empty_project) } - let(:project_hook) { create(:project_hook) } - - before(:each) do - project.hooks << [project_hook] - @data = { before: 'oldrev', after: 'newrev', ref: 'ref' } - - WebMock.stub_request(:post, project_hook.url) - end - - context 'when token is defined' do - let(:project_hook) { create(:project_hook, :token) } - - it 'POSTs to the webhook URL' do - project_hook.execute(@data, 'push_hooks') - expect(WebMock).to have_requested(:post, project_hook.url).with( - headers: { 'Content-Type' => 'application/json', - 'X-Gitlab-Event' => 'Push Hook', - 'X-Gitlab-Token' => project_hook.token } - ).once - end - end - - it "POSTs to the webhook URL" do - project_hook.execute(@data, 'push_hooks') - expect(WebMock).to have_requested(:post, project_hook.url).with( - headers: { 'Content-Type' => 'application/json', 'X-Gitlab-Event' => 'Push Hook' } - ).once - end - - it "POSTs the data as JSON" do - project_hook.execute(@data, 'push_hooks') - expect(WebMock).to have_requested(:post, project_hook.url).with( - headers: { 'Content-Type' => 'application/json', 'X-Gitlab-Event' => 'Push Hook' } - ).once - end - - it "catches exceptions" do - expect(WebHook).to receive(:post).and_raise("Some HTTP Post error") - - expect { project_hook.execute(@data, 'push_hooks') }.to raise_error(RuntimeError) - end - - it "handles SSL exceptions" do - expect(WebHook).to receive(:post).and_raise(OpenSSL::SSL::SSLError.new('SSL error')) + describe 'execute' do + let(:data) { { key: 'value' } } + let(:hook_name) { 'project hook' } - expect(project_hook.execute(@data, 'push_hooks')).to eq([false, 'SSL error']) + before do + expect(WebHookService).to receive(:new).with(hook, data, hook_name).and_call_original end - it "handles 200 status code" do - WebMock.stub_request(:post, project_hook.url).to_return(status: 200, body: "Success") + it '#execute' do + expect_any_instance_of(WebHookService).to receive(:execute) - expect(project_hook.execute(@data, 'push_hooks')).to eq([200, 'Success']) + hook.execute(data, hook_name) end - it "handles 2xx status codes" do - WebMock.stub_request(:post, project_hook.url).to_return(status: 201, body: "Success") + it '#async_execute' do + expect_any_instance_of(WebHookService).to receive(:async_execute) - expect(project_hook.execute(@data, 'push_hooks')).to eq([201, 'Success']) + hook.async_execute(data, hook_name) end end end diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb index 7c40cfd8253..f1e2a2cc518 100644 --- a/spec/models/key_spec.rb +++ b/spec/models/key_spec.rb @@ -66,14 +66,16 @@ describe Key, models: true do end it "does not accept the exact same key twice" do - create(:key, user: user) - expect(build(:key, user: user)).not_to be_valid + first_key = create(:key, user: user) + + expect(build(:key, user: user, key: first_key.key)).not_to be_valid end it "does not accept a duplicate key with a different comment" do - create(:key, user: user) - duplicate = build(:key, user: user) + first_key = create(:key, user: user) + duplicate = build(:key, user: user, key: first_key.key) duplicate.key << ' extra comment' + expect(duplicate).not_to be_valid end end diff --git a/spec/models/label_spec.rb b/spec/models/label_spec.rb index 80ca19acdda..84867e3d96b 100644 --- a/spec/models/label_spec.rb +++ b/spec/models/label_spec.rb @@ -49,6 +49,23 @@ describe Label, models: true do expect(label.color).to eq('#abcdef') end + + it 'uses default color if color is missing' do + label = described_class.new(color: nil) + + expect(label.color).to be(Label::DEFAULT_COLOR) + end + end + + describe '#text_color' do + it 'uses default color if color is missing' do + expect(LabelsHelper).to receive(:text_color_for_bg).with(Label::DEFAULT_COLOR). + and_return(spy) + + label = described_class.new(color: nil) + + label.text_color + end end describe '#title' do diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index 87ea2e70680..cf9c701e8c5 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/merge_request_spec.rb b/spec/models/merge_request_spec.rb index da915c49d3c..060754fab63 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -238,10 +238,10 @@ describe MergeRequest, models: true do end context 'when there are no MR diffs' do - it 'delegates to the compare object, setting no_collapse: true' do + it 'delegates to the compare object, setting expanded: true' do merge_request.compare = double(:compare) - expect(merge_request.compare).to receive(:diffs).with(options.merge(no_collapse: true)) + expect(merge_request.compare).to receive(:diffs).with(options.merge(expanded: true)) merge_request.diffs(options) end @@ -1178,7 +1178,7 @@ describe MergeRequest, models: true do end describe "#reload_diff" do - let(:note) { create(:diff_note_on_merge_request, project: subject.project, noteable: subject) } + let(:discussion) { create(:diff_note_on_merge_request, project: subject.project, noteable: subject).to_discussion } let(:commit) { subject.project.commit(sample_commit.id) } @@ -1197,7 +1197,7 @@ describe MergeRequest, models: true do subject.reload_diff end - it "updates diff note positions" do + it "updates diff discussion positions" do old_diff_refs = subject.diff_refs # Update merge_request_diff so that #diff_refs will return commit.diff_refs @@ -1211,15 +1211,15 @@ describe MergeRequest, models: true do subject.merge_request_diff(true) end - expect(Notes::DiffPositionUpdateService).to receive(:new).with( + expect(Discussions::UpdateDiffPositionService).to receive(:new).with( subject.project, subject.author, old_diff_refs: old_diff_refs, new_diff_refs: commit.diff_refs, - paths: note.position.paths + paths: discussion.position.paths ).and_call_original - expect_any_instance_of(Notes::DiffPositionUpdateService).to receive(:execute).with(note) + expect_any_instance_of(Discussions::UpdateDiffPositionService).to receive(:execute).with(discussion).and_call_original expect_any_instance_of(DiffNote).to receive(:save).once subject.reload_diff(subject.author) @@ -1243,7 +1243,7 @@ describe MergeRequest, models: true do end end - describe "#diff_sha_refs" do + describe "#diff_refs" do context "with diffs" do subject { create(:merge_request, :with_diffs) } @@ -1252,7 +1252,7 @@ describe MergeRequest, models: true do expect_any_instance_of(Repository).not_to receive(:commit) - subject.diff_sha_refs + subject.diff_refs end it "returns expected diff_refs" do @@ -1262,7 +1262,7 @@ describe MergeRequest, models: true do head_sha: subject.merge_request_diff.head_commit_sha ) - expect(subject.diff_sha_refs).to eq(expected_diff_refs) + expect(subject.diff_refs).to eq(expected_diff_refs) end end end diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index e3e8e6d571c..aa1ce89ffd7 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 312302afdbb..0e74f1ab1bd 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -238,8 +238,8 @@ describe Namespace, models: true do end context 'in sub-groups' do - let(:parent) { create(:namespace, path: 'parent') } - let(:child) { create(:namespace, parent: parent, path: 'child') } + let(:parent) { create(:group, path: 'parent') } + let(:child) { create(:group, parent: parent, path: 'child') } let!(:project) { create(:project_empty_repo, namespace: child) } let(:path_in_dir) { File.join(repository_storage_path, 'parent', 'child') } let(:deleted_path) { File.join('parent', "child+#{child.id}+deleted") } @@ -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 9b711bfc007..4161b9158b1 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 4bca0229e7a..0ee050196e4 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -22,49 +22,50 @@ describe JiraService, models: true do it { is_expected.not_to validate_presence_of(:url) } end - end - describe '#reference_pattern' do - it_behaves_like 'allows project key on reference pattern' + 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 'does not allow # on the code' do - expect(subject.reference_pattern.match('#123')).to be_nil - expect(subject.reference_pattern.match('1#23#12')).to be_nil - end - end + it 'is valid when all fields have required values' do + expect(service).to be_valid + end - describe '#can_test?' do - let(:jira_service) { described_class.new } + it 'is not valid when url is not a valid url' do + service.url = 'not valid' - it 'returns false if username is blank' do - allow(jira_service).to receive_messages( - url: 'http://jira.example.com', - username: '', - password: '12345678' - ) + expect(service).not_to be_valid + end - expect(jira_service.can_test?).to be_falsy - end + it 'is not valid when api url is not a valid url' do + service.api_url = 'not valid' - it 'returns false if password is blank' do - allow(jira_service).to receive_messages( - url: 'http://jira.example.com', - username: 'tester', - password: '' - ) + 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(jira_service.can_test?).to be_falsy + expect(service).to be_valid + end end + end - it 'returns true if password and username are present' do - jira_service = described_class.new - allow(jira_service).to receive_messages( - url: 'http://jira.example.com', - username: 'tester', - password: '12345678' - ) + describe '#reference_pattern' do + it_behaves_like 'allows project key on reference pattern' - expect(jira_service.can_test?).to be_truthy + it 'does not allow # on the code' do + expect(subject.reference_pattern.match('#123')).to be_nil + expect(subject.reference_pattern.match('1#23#12')).to be_nil end end @@ -97,6 +98,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 +189,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 - expect(WebMock).to have_requested(:get, project_url) + it 'tries to get JIRA project with URL when API URL not set' do + test_settings('jira.example.com') + end + + 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 +223,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 +299,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 +310,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 +361,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 +376,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_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb index c1c2f2a7219..0dcf4a4b5d6 100644 --- a/spec/models/project_services/kubernetes_service_spec.rb +++ b/spec/models/project_services/kubernetes_service_spec.rb @@ -13,7 +13,7 @@ describe KubernetesService, models: true, caching: true do let(:discovery_url) { service.api_url + '/api/v1' } let(:discovery_response) { { body: kube_discovery_body.to_json } } - let(:pods_url) { service.api_url + "/api/v1/namespaces/#{service.namespace}/pods" } + let(:pods_url) { service.api_url + "/api/v1/namespaces/#{service.actual_namespace}/pods" } let(:pods_response) { { body: kube_pods_body(kube_pod).to_json } } def stub_kubeclient_discover @@ -100,7 +100,35 @@ describe KubernetesService, models: true, caching: true do it 'sets the namespace to the default' do expect(kube_namespace).not_to be_nil - expect(kube_namespace[:placeholder]).to match(/\A#{Gitlab::Regex::PATH_REGEX_STR}-\d+\z/) + expect(kube_namespace[:placeholder]).to match(/\A#{Gitlab::PathRegex::PATH_REGEX_STR}-\d+\z/) + end + end + end + + describe '#actual_namespace' do + subject { service.actual_namespace } + + it "returns the default namespace" do + is_expected.to eq(service.send(:default_namespace)) + end + + context 'when namespace is specified' do + before do + service.namespace = 'my-namespace' + end + + it "returns the user-namespace" do + is_expected.to eq('my-namespace') + end + end + + context 'when service is not assigned to project' do + before do + service.project = nil + end + + it "does not return namespace" do + is_expected.to be_nil end end end @@ -187,13 +215,14 @@ describe KubernetesService, models: true, caching: true do kube_namespace = subject.predefined_variables.find { |h| h[:key] == 'KUBE_NAMESPACE' } expect(kube_namespace).not_to be_nil - expect(kube_namespace[:value]).to match(/\A#{Gitlab::Regex::PATH_REGEX_STR}-\d+\z/) + expect(kube_namespace[:value]).to match(/\A#{Gitlab::PathRegex::PATH_REGEX_STR}-\d+\z/) end end end describe '#terminals' do let(:environment) { build(:environment, project: project, name: "env", slug: "env-000000") } + subject { service.terminals(environment) } context 'with invalid pods' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index f2b4e9070b4..86ab2550bfb 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -948,6 +948,20 @@ describe Project, models: true do end end + describe '.starred_by' do + it 'returns only projects starred by the given user' do + user1 = create(:user) + user2 = create(:user) + project1 = create(:empty_project) + project2 = create(:empty_project) + create(:empty_project) + user1.toggle_star(project1) + user2.toggle_star(project2) + + expect(Project.starred_by(user1)).to contain_exactly(project1) + end + end + describe '.visible_to_user' do let!(:project) { create(:empty_project, :private) } let!(:user) { create(:user) } @@ -1431,6 +1445,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, @@ -1710,6 +1749,90 @@ describe Project, models: true do end end + describe '#secret_variables_for' do + let(:project) { create(:empty_project) } + + let!(:secret_variable) do + create(:ci_variable, value: 'secret', project: project) + end + + let!(:protected_variable) do + create(:ci_variable, :protected, value: 'protected', project: project) + end + + subject { project.secret_variables_for('ref') } + + shared_examples 'ref is protected' do + it 'contains all the variables' do + is_expected.to contain_exactly(secret_variable, protected_variable) + end + end + + context 'when the ref is not protected' do + before do + stub_application_setting( + default_branch_protection: Gitlab::Access::PROTECTION_NONE) + end + + it 'contains only the secret variables' do + is_expected.to contain_exactly(secret_variable) + end + end + + context 'when the ref is a protected branch' do + before do + create(:protected_branch, name: 'ref', project: project) + end + + it_behaves_like 'ref is protected' + end + + context 'when the ref is a protected tag' do + before do + create(:protected_tag, name: 'ref', project: project) + end + + it_behaves_like 'ref is protected' + end + end + + describe '#protected_for?' do + let(:project) { create(:empty_project) } + + subject { project.protected_for?('ref') } + + context 'when the ref is not protected' do + before do + stub_application_setting( + default_branch_protection: Gitlab::Access::PROTECTION_NONE) + end + + it 'returns false' do + is_expected.to be_falsey + end + end + + context 'when the ref is a protected branch' do + before do + create(:protected_branch, name: 'ref', project: project) + end + + it 'returns true' do + is_expected.to be_truthy + end + end + + context 'when the ref is a protected tag' do + before do + create(:protected_tag, name: 'ref', project: project) + end + + it 'returns true' do + is_expected.to be_truthy + end + end + end + describe '#update_project_statistics' do let(:project) { create(:empty_project) } @@ -1884,19 +2007,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 942eeab251d..362565506e5 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) } @@ -312,69 +327,114 @@ describe ProjectTeam, models: true do end end - shared_examples_for "#max_member_access_for_users" do |enable_request_store| - describe "#max_member_access_for_users" do + shared_examples 'max member access for users' do + let(:project) { create(:project) } + let(:group) { create(:group) } + let(:second_group) { create(:group) } + + let(:master) { create(:user) } + let(:reporter) { create(:user) } + let(:guest) { create(:user) } + + let(:promoted_guest) { create(:user) } + + let(:group_developer) { create(:user) } + let(:second_developer) { create(:user) } + + let(:user_without_access) { create(:user) } + let(:second_user_without_access) { create(:user) } + + let(:users) do + [master, reporter, promoted_guest, guest, group_developer, second_developer, user_without_access].map(&:id) + end + + let(:expected) do + { + master.id => Gitlab::Access::MASTER, + reporter.id => Gitlab::Access::REPORTER, + promoted_guest.id => Gitlab::Access::DEVELOPER, + guest.id => Gitlab::Access::GUEST, + group_developer.id => Gitlab::Access::DEVELOPER, + second_developer.id => Gitlab::Access::MASTER, + user_without_access.id => Gitlab::Access::NO_ACCESS + } + end + + before do + project.add_master(master) + project.add_reporter(reporter) + project.add_guest(promoted_guest) + project.add_guest(guest) + + project.project_group_links.create( + group: group, + group_access: Gitlab::Access::DEVELOPER + ) + + group.add_master(promoted_guest) + group.add_developer(group_developer) + group.add_developer(second_developer) + + project.project_group_links.create( + group: second_group, + group_access: Gitlab::Access::MASTER + ) + + second_group.add_master(second_developer) + end + + it 'returns correct roles for different users' do + expect(project.team.max_member_access_for_user_ids(users)).to eq(expected) + end + end + + describe '#max_member_access_for_user_ids' do + context 'with RequestStore enabled' do before do - RequestStore.begin! if enable_request_store + RequestStore.begin! end after do - if enable_request_store - RequestStore.end! - RequestStore.clear! - end + RequestStore.end! + RequestStore.clear! end - it 'returns correct roles for different users' do - master = create(:user) - reporter = create(:user) - promoted_guest = create(:user) - guest = create(:user) - project = create(:empty_project) + include_examples 'max member access for users' - project.add_master(master) - project.add_reporter(reporter) - project.add_guest(promoted_guest) - project.add_guest(guest) + def access_levels(users) + project.team.max_member_access_for_user_ids(users) + end + + it 'does not perform extra queries when asked for users who have already been found' do + access_levels(users) - group = create(:group) - group_developer = create(:user) - second_developer = create(:user) - project.project_group_links.create( - group: group, - group_access: Gitlab::Access::DEVELOPER) - - group.add_master(promoted_guest) - group.add_developer(group_developer) - group.add_developer(second_developer) - - second_group = create(:group) - project.project_group_links.create( - group: second_group, - group_access: Gitlab::Access::MASTER) - second_group.add_master(second_developer) - - users = [master, reporter, promoted_guest, guest, group_developer, second_developer].map(&:id) - - expected = { - master.id => Gitlab::Access::MASTER, - reporter.id => Gitlab::Access::REPORTER, - promoted_guest.id => Gitlab::Access::DEVELOPER, - guest.id => Gitlab::Access::GUEST, - group_developer.id => Gitlab::Access::DEVELOPER, - second_developer.id => Gitlab::Access::MASTER - } - - expect(project.team.max_member_access_for_user_ids(users)).to eq(expected) + expect { access_levels(users) }.not_to exceed_query_limit(0) + + expect(access_levels(users)).to eq(expected) end - end - end - describe '#max_member_access_for_users with RequestStore' do - it_behaves_like "#max_member_access_for_users", true - end + it 'only requests the extra users when uncached users are passed' do + new_user = create(:user) + second_new_user = create(:user) + all_users = users + [new_user.id, second_new_user.id] + + expected_all = expected.merge(new_user.id => Gitlab::Access::NO_ACCESS, + second_new_user.id => Gitlab::Access::NO_ACCESS) - describe '#max_member_access_for_users without RequestStore' do - it_behaves_like "#max_member_access_for_users", false + access_levels(users) + + queries = ActiveRecord::QueryRecorder.new { access_levels(all_users) } + + expect(queries.count).to eq(1) + expect(queries.log_message).to match(/\W#{new_user.id}\W/) + expect(queries.log_message).to match(/\W#{second_new_user.id}\W/) + expect(queries.log_message).not_to match(/\W#{promoted_guest.id}\W/) + expect(access_levels(all_users)).to eq(expected_all) + end + end + + context 'with RequestStore disabled' do + include_examples 'max member access for users' + end end end diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index 969e9f7a130..224067f58dd 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 aabdac4bb75..1c3541da44f 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -22,7 +22,7 @@ describe User, models: true do it { is_expected.to have_many(:deploy_keys).dependent(:destroy) } it { is_expected.to have_many(:events).dependent(:destroy) } it { is_expected.to have_many(:recent_events).class_name('Event') } - it { is_expected.to have_many(:issues).dependent(:restrict_with_exception) } + it { is_expected.to have_many(:issues).dependent(:destroy) } it { is_expected.to have_many(:notes).dependent(:destroy) } it { is_expected.to have_many(:merge_requests).dependent(:destroy) } it { is_expected.to have_many(:identities).dependent(:destroy) } @@ -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') } @@ -1506,25 +1496,6 @@ describe User, models: true do end end - describe '#viewable_starred_projects' do - let(:user) { create(:user) } - let(:public_project) { create(:empty_project, :public) } - let(:private_project) { create(:empty_project, :private) } - let(:private_viewable_project) { create(:empty_project, :private) } - - before do - private_viewable_project.team << [user, Gitlab::Access::MASTER] - - [public_project, private_project, private_viewable_project].each do |project| - user.toggle_star(project) - end - end - - it 'returns only starred projects the user can view' do - expect(user.viewable_starred_projects).not_to include(private_project) - end - end - describe '#projects_with_reporter_access_limited_to' do let(:project1) { create(:empty_project) } let(:project2) { create(:empty_project) } @@ -1561,48 +1532,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' } + + subject { user.all_expanded_groups } - # Add more data to ensure method does not include wrong groups - create(:group).add_owner(create(:user)) + 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 +1648,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 +1757,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 +1772,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 2077c14ff7a..4c37a553227 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/presenters/conversational_development_index/metric_presenter_spec.rb b/spec/presenters/conversational_development_index/metric_presenter_spec.rb new file mode 100644 index 00000000000..1e015c71f5b --- /dev/null +++ b/spec/presenters/conversational_development_index/metric_presenter_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe ConversationalDevelopmentIndex::MetricPresenter do + subject { described_class.new(metric) } + let(:metric) { build(:conversational_development_index_metric) } + + describe '#cards' do + it 'includes instance score, leader score and percentage score' do + issues_card = subject.cards.first + + expect(issues_card.instance_score).to eq 1.234 + expect(issues_card.leader_score).to eq 9.256 + expect(issues_card.percentage_score).to be_within(0.1).of(13.3) + end + end + + describe '#idea_to_production_steps' do + it 'returns percentage score when it depends on a single feature' do + code_step = subject.idea_to_production_steps.fourth + + expect(code_step.percentage_score).to be_within(0.1).of(50.0) + end + + it 'returns percentage score when it depends on two features' do + issue_step = subject.idea_to_production_steps.second + + expect(issue_step.percentage_score).to be_within(0.1).of(53.0) + end + end + + describe '#average_percentage_score' do + it 'calculates an average value across all the features' do + expect(subject.average_percentage_score).to be_within(0.1).of(55.8) + end + end +end diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb index 1c163cee152..6b637a03b6f 100644 --- a/spec/requests/api/commit_statuses_spec.rb +++ b/spec/requests/api/commit_statuses_spec.rb @@ -16,8 +16,8 @@ describe API::CommitStatuses do let(:get_url) { "/projects/#{project.id}/repository/commits/#{sha}/statuses" } context 'ci commit exists' do - let!(:master) { project.pipelines.create(sha: commit.id, ref: 'master') } - let!(:develop) { project.pipelines.create(sha: commit.id, ref: 'develop') } + let!(:master) { project.pipelines.create(source: :push, sha: commit.id, ref: 'master') } + let!(:develop) { project.pipelines.create(source: :push, sha: commit.id, ref: 'develop') } context "reporter user" do let(:statuses_id) { json_response.map { |status| status['id'] } } diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index 0b0e4c2b112..b0c265b6453 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') } @@ -486,7 +485,7 @@ describe API::Commits do end it "returns status for CI" do - pipeline = project.ensure_pipeline('master', project.repository.commit.sha) + pipeline = project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha) pipeline.update(status: 'success') get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) @@ -496,7 +495,7 @@ describe API::Commits do end it "returns status for CI when pipeline is created" do - project.ensure_pipeline('master', project.repository.commit.sha) + project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha) get api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb new file mode 100644 index 00000000000..f169e6661d1 --- /dev/null +++ b/spec/requests/api/features_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +describe API::Features do + let(:user) { create(:user) } + let(:admin) { create(:admin) } + + describe 'GET /features' do + let(:expected_features) do + [ + { + 'name' => 'feature_1', + 'state' => 'on', + 'gates' => [{ 'key' => 'boolean', 'value' => true }] + }, + { + 'name' => 'feature_2', + 'state' => 'off', + 'gates' => [{ 'key' => 'boolean', 'value' => false }] + } + ] + end + + before do + Feature.get('feature_1').enable + Feature.get('feature_2').disable + end + + it 'returns a 401 for anonymous users' do + get api('/features') + + expect(response).to have_http_status(401) + end + + it 'returns a 403 for users' do + get api('/features', user) + + expect(response).to have_http_status(403) + end + + it 'returns the feature list for admins' do + get api('/features', admin) + + expect(response).to have_http_status(200) + expect(json_response).to match_array(expected_features) + end + end + + describe 'POST /feature' do + let(:feature_name) { 'my_feature' } + it 'returns a 401 for anonymous users' do + post api("/features/#{feature_name}") + + expect(response).to have_http_status(401) + end + + it 'returns a 403 for users' do + post api("/features/#{feature_name}", user) + + expect(response).to have_http_status(403) + end + + it 'creates an enabled feature if passed true' do + post api("/features/#{feature_name}", admin), value: 'true' + + expect(response).to have_http_status(201) + expect(Feature.get(feature_name)).to be_enabled + end + + it 'creates a feature with the given percentage if passed an integer' do + post api("/features/#{feature_name}", admin), value: '50' + + expect(response).to have_http_status(201) + expect(Feature.get(feature_name).percentage_of_time_value).to be(50) + end + + context 'when the feature exists' do + let(:feature) { Feature.get(feature_name) } + + before do + feature.disable # This also persists the feature on the DB + end + + it 'enables the feature if passed true' do + post api("/features/#{feature_name}", admin), value: 'true' + + expect(response).to have_http_status(201) + expect(feature).to be_enabled + end + + context 'with a pre-existing percentage value' do + before do + feature.enable_percentage_of_time(50) + end + + it 'updates the percentage of time if passed an integer' do + post api("/features/#{feature_name}", admin), value: '30' + + expect(response).to have_http_status(201) + expect(Feature.get(feature_name).percentage_of_time_value).to be(30) + end + end + end + end +end diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index 90b36374ded..bb53796cbd7 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/pipeline_schedules_spec.rb b/spec/requests/api/pipeline_schedules_spec.rb new file mode 100644 index 00000000000..85d11deb26f --- /dev/null +++ b/spec/requests/api/pipeline_schedules_spec.rb @@ -0,0 +1,297 @@ +require 'spec_helper' + +describe API::PipelineSchedules do + set(:developer) { create(:user) } + set(:user) { create(:user) } + set(:project) { create(:project) } + + before do + project.add_developer(developer) + end + + describe 'GET /projects/:id/pipeline_schedules' do + context 'authenticated user with valid permissions' do + let(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: developer) } + + before do + pipeline_schedule.pipelines << build(:ci_pipeline, project: project) + end + + it 'returns list of pipeline_schedules' do + get api("/projects/#{project.id}/pipeline_schedules", developer) + + expect(response).to have_http_status(:ok) + expect(response).to include_pagination_headers + expect(response).to match_response_schema('pipeline_schedules') + end + + it 'avoids N + 1 queries' do + control_count = ActiveRecord::QueryRecorder.new do + get api("/projects/#{project.id}/pipeline_schedules", developer) + end.count + + create_list(:ci_pipeline_schedule, 10, project: project) + .each do |pipeline_schedule| + create(:user).tap do |user| + project.add_developer(user) + pipeline_schedule.update_attributes(owner: user) + end + pipeline_schedule.pipelines << build(:ci_pipeline, project: project) + end + + expect do + get api("/projects/#{project.id}/pipeline_schedules", developer) + end.not_to exceed_query_limit(control_count) + end + + %w[active inactive].each do |target| + context "when scope is #{target}" do + before do + create(:ci_pipeline_schedule, project: project, active: active?(target)) + end + + it 'returns matched pipeline schedules' do + get api("/projects/#{project.id}/pipeline_schedules", developer), scope: target + + expect(json_response.map{ |r| r['active'] }).to all(eq(active?(target))) + end + end + + def active?(str) + (str == 'active') ? true : false + end + end + end + + context 'authenticated user with invalid permissions' do + it 'does not return pipeline_schedules list' do + get api("/projects/#{project.id}/pipeline_schedules", user) + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not return pipeline_schedules list' do + get api("/projects/#{project.id}/pipeline_schedules") + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'GET /projects/:id/pipeline_schedules/:pipeline_schedule_id' do + let(:pipeline_schedule) { create(:ci_pipeline_schedule, project: project, owner: developer) } + + before do + pipeline_schedule.pipelines << build(:ci_pipeline, project: project) + end + + context 'authenticated user with valid permissions' do + it 'returns pipeline_schedule details' do + get api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer) + + expect(response).to have_http_status(:ok) + expect(response).to match_response_schema('pipeline_schedule') + end + + it 'responds with 404 Not Found if requesting non-existing pipeline_schedule' do + get api("/projects/#{project.id}/pipeline_schedules/-5", developer) + + expect(response).to have_http_status(:not_found) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not return pipeline_schedules list' do + get api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", user) + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not return pipeline_schedules list' do + get api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}") + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /projects/:id/pipeline_schedules' do + let(:params) { attributes_for(:ci_pipeline_schedule) } + + context 'authenticated user with valid permissions' do + context 'with required parameters' do + it 'creates pipeline_schedule' do + expect do + post api("/projects/#{project.id}/pipeline_schedules", developer), + params + end.to change { project.pipeline_schedules.count }.by(1) + + expect(response).to have_http_status(:created) + expect(response).to match_response_schema('pipeline_schedule') + expect(json_response['description']).to eq(params[:description]) + expect(json_response['ref']).to eq(params[:ref]) + expect(json_response['cron']).to eq(params[:cron]) + expect(json_response['cron_timezone']).to eq(params[:cron_timezone]) + expect(json_response['owner']['id']).to eq(developer.id) + end + end + + context 'without required parameters' do + it 'does not create pipeline_schedule' do + post api("/projects/#{project.id}/pipeline_schedules", developer) + + expect(response).to have_http_status(:bad_request) + end + end + + context 'when cron has validation error' do + it 'does not create pipeline_schedule' do + post api("/projects/#{project.id}/pipeline_schedules", developer), + params.merge('cron' => 'invalid-cron') + + expect(response).to have_http_status(:bad_request) + expect(json_response['message']).to have_key('cron') + end + end + end + + context 'authenticated user with invalid permissions' do + it 'does not create pipeline_schedule' do + post api("/projects/#{project.id}/pipeline_schedules", user), params + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not create pipeline_schedule' do + post api("/projects/#{project.id}/pipeline_schedules"), params + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PUT /projects/:id/pipeline_schedules/:pipeline_schedule_id' do + let(:pipeline_schedule) do + create(:ci_pipeline_schedule, project: project, owner: developer) + end + + context 'authenticated user with valid permissions' do + it 'updates cron' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer), + cron: '1 2 3 4 *' + + expect(response).to have_http_status(:ok) + expect(response).to match_response_schema('pipeline_schedule') + expect(json_response['cron']).to eq('1 2 3 4 *') + end + + context 'when cron has validation error' do + it 'does not update pipeline_schedule' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer), + cron: 'invalid-cron' + + expect(response).to have_http_status(:bad_request) + expect(json_response['message']).to have_key('cron') + end + end + end + + context 'authenticated user with invalid permissions' do + it 'does not update pipeline_schedule' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", user) + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not update pipeline_schedule' do + put api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}") + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'POST /projects/:id/pipeline_schedules/:pipeline_schedule_id/take_ownership' do + let(:pipeline_schedule) do + create(:ci_pipeline_schedule, project: project, owner: developer) + end + + context 'authenticated user with valid permissions' do + it 'updates owner' do + post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/take_ownership", developer) + + expect(response).to have_http_status(:created) + expect(response).to match_response_schema('pipeline_schedule') + end + end + + context 'authenticated user with invalid permissions' do + it 'does not update owner' do + post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/take_ownership", user) + + expect(response).to have_http_status(:not_found) + end + end + + context 'unauthenticated user' do + it 'does not update owner' do + post api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}/take_ownership") + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'DELETE /projects/:id/pipeline_schedules/:pipeline_schedule_id' do + let(:master) { create(:user) } + + let!(:pipeline_schedule) do + create(:ci_pipeline_schedule, project: project, owner: developer) + end + + before do + project.add_master(master) + end + + context 'authenticated user with valid permissions' do + it 'deletes pipeline_schedule' do + expect do + delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", master) + end.to change { project.pipeline_schedules.count }.by(-1) + + expect(response).to have_http_status(:accepted) + expect(response).to match_response_schema('pipeline_schedule') + end + + it 'responds with 404 Not Found if requesting non-existing pipeline_schedule' do + delete api("/projects/#{project.id}/pipeline_schedules/-5", master) + + expect(response).to have_http_status(:not_found) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not delete pipeline_schedule' do + delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}", developer) + + expect(response).to have_http_status(:forbidden) + end + end + + context 'unauthenticated user' do + it 'does not delete pipeline_schedule' do + delete api("/projects/#{project.id}/pipeline_schedules/#{pipeline_schedule.id}") + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb index f9e5316b3de..9e6957e9922 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 d5c3b5b34ad..5c13cea69fb 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 @@ -317,15 +316,15 @@ describe API::Projects do expect(project.path).to eq('foo_project') end - it 'creates new project name and path and returns 201' do - expect { post api('/projects', user), path: 'foo-Project', name: 'Foo Project' }. + it 'creates new project with name and path and returns 201' do + expect { post api('/projects', user), path: 'path-project-Foo', name: 'Foo Project' }. to change { Project.count }.by(1) expect(response).to have_http_status(201) project = Project.first expect(project.name).to eq('Foo Project') - expect(project.path).to eq('foo-Project') + expect(project.path).to eq('path-project-Foo') end it 'creates last project before reaching project limit' do @@ -391,6 +390,14 @@ describe API::Projects do expect(json_response['visibility']).to eq('private') end + it 'sets tag list to a project' do + project = attributes_for(:project, tag_list: %w[tagFirst tagSecond]) + + post api('/projects', user), project + + expect(json_response['tag_list']).to eq(%w[tagFirst tagSecond]) + end + it 'sets a project as allowing merge even if build fails' do project = attributes_for(:project, { only_allow_merge_if_pipeline_succeeds: false }) post api('/projects', user), project @@ -463,9 +470,25 @@ describe API::Projects do before { project } before { admin } - it 'creates new project without path and return 201' do - expect { post api("/projects/user/#{user.id}", admin), name: 'foo' }.to change {Project.count}.by(1) + it 'creates new project without path but with name and return 201' do + expect { post api("/projects/user/#{user.id}", admin), name: 'Foo Project' }.to change {Project.count}.by(1) + expect(response).to have_http_status(201) + + project = Project.first + + expect(project.name).to eq('Foo Project') + expect(project.path).to eq('foo-project') + end + + it 'creates new project with name and path and returns 201' do + expect { post api("/projects/user/#{user.id}", admin), path: 'path-project-Foo', name: 'Foo Project' }. + to change { Project.count }.by(1) expect(response).to have_http_status(201) + + project = Project.first + + expect(project.name).to eq('Foo Project') + expect(project.path).to eq('path-project-Foo') end it 'responds with 400 on failure and not project' do @@ -612,6 +635,8 @@ describe API::Projects do expect(json_response['shared_runners_enabled']).to be_present expect(json_response['creator_id']).to be_present expect(json_response['namespace']).to be_present + expect(json_response['import_status']).to be_present + expect(json_response).to include("import_error") expect(json_response['avatar_url']).to be_nil expect(json_response['star_count']).to be_present expect(json_response['forks_count']).to be_present @@ -679,6 +704,20 @@ describe API::Projects do expect(json_response).to include 'statistics' end + it "includes import_error if user can admin project" do + get api("/projects/#{project.id}", user) + + expect(response).to have_http_status(200) + expect(json_response).to include("import_error") + end + + it "does not include import_error if user cannot admin project" do + get api("/projects/#{project.id}", user3) + + expect(response).to have_http_status(200) + expect(json_response).not_to include("import_error") + end + describe 'permissions' do context 'all projects' do before { project.team << [user, :master] } @@ -784,19 +823,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 +1129,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 = {} @@ -1442,6 +1480,8 @@ describe API::Projects do expect(json_response['owner']['id']).to eq(user2.id) expect(json_response['namespace']['id']).to eq(user2.namespace.id) expect(json_response['forked_from_project']['id']).to eq(project.id) + expect(json_response['import_status']).to eq('started') + expect(json_response).to include("import_error") end it 'forks if user is admin' do @@ -1453,6 +1493,8 @@ describe API::Projects do expect(json_response['owner']['id']).to eq(admin.id) expect(json_response['namespace']['id']).to eq(admin.namespace.id) expect(json_response['forked_from_project']['id']).to eq(project.id) + expect(json_response['import_status']).to eq('started') + expect(json_response).to include("import_error") end it 'fails on missing project access for the project to fork' do @@ -1573,7 +1615,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/users_spec.rb b/spec/requests/api/users_spec.rb index 4919ad19833..1c33b8f9502 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -287,7 +287,7 @@ describe API::Users do expect(json_response['message']['projects_limit']). to eq(['must be greater than or equal to 0']) expect(json_response['message']['username']). - to eq([Gitlab::Regex.namespace_regex_message]) + to eq([Gitlab::PathRegex.namespace_format_message]) end it "is not available for non admin users" do @@ -459,7 +459,7 @@ describe API::Users do expect(json_response['message']['projects_limit']). to eq(['must be greater than or equal to 0']) expect(json_response['message']['username']). - to eq([Gitlab::Regex.namespace_regex_message]) + to eq([Gitlab::PathRegex.namespace_format_message]) end it 'returns 400 if provider is missing for identity update' do @@ -702,6 +702,7 @@ describe API::Users do describe "DELETE /users/:id" do let!(:namespace) { user.namespace } + let!(:issue) { create(:issue, author: user) } before { admin } it "deletes user" do @@ -733,6 +734,25 @@ describe API::Users do expect(response).to have_http_status(404) end + + context "hard delete disabled" do + it "moves contributions to the ghost user" do + Sidekiq::Testing.inline! { delete api("/users/#{user.id}", admin) } + + expect(response).to have_http_status(204) + expect(issue.reload).to be_persisted + expect(issue.author.ghost?).to be_truthy + end + end + + context "hard delete enabled" do + it "removes contributions" do + Sidekiq::Testing.inline! { delete api("/users/#{user.id}?hard_delete=true", admin) } + + expect(response).to have_http_status(204) + expect(Issue.exists?(issue.id)).to be_falsy + end + end end describe "GET /user" do diff --git a/spec/requests/api/v3/commits_spec.rb b/spec/requests/api/v3/commits_spec.rb index c2e8c3ae6f7..4a4a5dc5c7c 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') } @@ -387,7 +386,7 @@ describe API::V3::Commits do end it "returns status for CI" do - pipeline = project.ensure_pipeline('master', project.repository.commit.sha) + pipeline = project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha) pipeline.update(status: 'success') get v3_api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) @@ -397,7 +396,7 @@ describe API::V3::Commits do end it "returns status for CI when pipeline is created" do - project.ensure_pipeline('master', project.repository.commit.sha) + project.pipelines.create(source: :push, ref: 'master', sha: project.repository.commit.sha) get v3_api("/projects/#{project.id}/repository/commits/#{project.repository.commit.id}", user) diff --git a/spec/requests/api/v3/deploy_keys_spec.rb b/spec/requests/api/v3/deploy_keys_spec.rb index b61b2b618a6..94f4d93a8dc 100644 --- a/spec/requests/api/v3/deploy_keys_spec.rb +++ b/spec/requests/api/v3/deploy_keys_spec.rb @@ -105,6 +105,15 @@ describe API::V3::DeployKeys do expect(response).to have_http_status(201) end + + it 'accepts can_push parameter' do + key_attrs = attributes_for :write_access_key + + post v3_api("/projects/#{project.id}/#{path}", admin), key_attrs + + expect(response).to have_http_status(201) + expect(json_response['can_push']).to eq(true) + end end describe "DELETE /projects/:id/#{path}/:key_id" do diff --git a/spec/requests/api/v3/groups_spec.rb b/spec/requests/api/v3/groups_spec.rb index bc261b5e07c..98e8c954909 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 dc7c3d125b1..47cca4275af 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, @@ -166,7 +165,7 @@ describe API::V3::Projects do expect(json_response).to satisfy do |response| response.one? do |entry| - entry.has_key?('permissions') && + entry.key?('permissions') && entry['name'] == project.name && entry['owner']['username'] == user.username end @@ -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/requests/api/variables_spec.rb b/spec/requests/api/variables_spec.rb index 63d6d3001ac..83673864fe7 100644 --- a/spec/requests/api/variables_spec.rb +++ b/spec/requests/api/variables_spec.rb @@ -42,6 +42,7 @@ describe API::Variables do expect(response).to have_http_status(200) expect(json_response['value']).to eq(variable.value) + expect(json_response['protected']).to eq(variable.protected?) end it 'responds with 404 Not Found if requesting non-existing variable' do @@ -72,12 +73,13 @@ describe API::Variables do context 'authorized user with proper permissions' do it 'creates variable' do expect do - post api("/projects/#{project.id}/variables", user), key: 'TEST_VARIABLE_2', value: 'VALUE_2' + post api("/projects/#{project.id}/variables", user), key: 'TEST_VARIABLE_2', value: 'VALUE_2', protected: true end.to change{project.variables.count}.by(1) expect(response).to have_http_status(201) expect(json_response['key']).to eq('TEST_VARIABLE_2') expect(json_response['value']).to eq('VALUE_2') + expect(json_response['protected']).to be_truthy end it 'does not allow to duplicate variable key' do @@ -112,13 +114,14 @@ describe API::Variables do initial_variable = project.variables.first value_before = initial_variable.value - put api("/projects/#{project.id}/variables/#{variable.key}", user), value: 'VALUE_1_UP' + put api("/projects/#{project.id}/variables/#{variable.key}", user), value: 'VALUE_1_UP', protected: true updated_variable = project.variables.first expect(response).to have_http_status(200) expect(value_before).to eq(variable.value) expect(updated_variable.value).to eq('VALUE_1_UP') + expect(updated_variable).to be_protected end it 'responds with 404 Not Found if requesting non-existing variable' do diff --git a/spec/routing/admin_routing_spec.rb b/spec/routing/admin_routing_spec.rb index e5fc0b676af..179fc9733ad 100644 --- a/spec/routing/admin_routing_spec.rb +++ b/spec/routing/admin_routing_spec.rb @@ -103,6 +103,18 @@ describe Admin::HooksController, "routing" do end end +# admin_hook_hook_log_retry GET /admin/hooks/:hook_id/hook_logs/:id/retry(.:format) admin/hook_logs#retry +# admin_hook_hook_log GET /admin/hooks/:hook_id/hook_logs/:id(.:format) admin/hook_logs#show +describe Admin::HookLogsController, 'routing' do + it 'to #retry' do + expect(get('/admin/hooks/1/hook_logs/1/retry')).to route_to('admin/hook_logs#retry', hook_id: '1', id: '1') + end + + it 'to #show' do + expect(get('/admin/hooks/1/hook_logs/1')).to route_to('admin/hook_logs#show', hook_id: '1', id: '1') + end +end + # admin_logs GET /admin/logs(.:format) admin/logs#show describe Admin::LogsController, "routing" do it "to #show" do diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index a391c046f92..54417f6b3e1 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -349,6 +349,18 @@ describe 'project routing' do end end + # retry_namespace_project_hook_hook_log GET /:project_id/hooks/:hook_id/hook_logs/:id/retry(.:format) projects/hook_logs#retry + # namespace_project_hook_hook_log GET /:project_id/hooks/:hook_id/hook_logs/:id(.:format) projects/hook_logs#show + describe Projects::HookLogsController, 'routing' do + it 'to #retry' do + expect(get('/gitlab/gitlabhq/hooks/1/hook_logs/1/retry')).to route_to('projects/hook_logs#retry', namespace_id: 'gitlab', project_id: 'gitlabhq', hook_id: '1', id: '1') + end + + it 'to #show' do + expect(get('/gitlab/gitlabhq/hooks/1/hook_logs/1')).to route_to('projects/hook_logs#show', namespace_id: 'gitlab', project_id: 'gitlabhq', hook_id: '1', id: '1') + end + end + # project_commit GET /:project_id/commit/:id(.:format) commit#show {id: /\h{7,40}/, project_id: /[^\/]+/} describe Projects::CommitController, 'routing' do it 'to #show' do diff --git a/spec/rubocop/cop/activerecord_serialize_spec.rb b/spec/rubocop/cop/activerecord_serialize_spec.rb new file mode 100644 index 00000000000..a303b16d264 --- /dev/null +++ b/spec/rubocop/cop/activerecord_serialize_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' +require 'rubocop' +require 'rubocop/rspec/support' +require_relative '../../../rubocop/cop/activerecord_serialize' + +describe RuboCop::Cop::ActiverecordSerialize do + include CopHelper + + subject(:cop) { described_class.new } + + context 'inside the app/models directory' do + it 'registers an offense when serialize is used' do + allow(cop).to receive(:in_models?).and_return(true) + + inspect_source(cop, 'serialize :foo') + + aggregate_failures do + expect(cop.offenses.size).to eq(1) + expect(cop.offenses.map(&:line)).to eq([1]) + end + end + end + + context 'outside the app/models directory' do + it 'does nothing' do + allow(cop).to receive(:in_models?).and_return(false) + + inspect_source(cop, 'serialize :foo') + + expect(cop.offenses).to be_empty + end + end +end 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 00000000000..968dcd6232e --- /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/serializers/build_action_entity_spec.rb b/spec/serializers/build_action_entity_spec.rb index 059deba5416..15720d86583 100644 --- a/spec/serializers/build_action_entity_spec.rb +++ b/spec/serializers/build_action_entity_spec.rb @@ -1,26 +1,26 @@ require 'spec_helper' describe BuildActionEntity do - let(:build) { create(:ci_build, name: 'test_build') } + let(:job) { create(:ci_build, name: 'test_job') } let(:request) { double('request') } let(:entity) do - described_class.new(build, request: spy('request')) + described_class.new(job, request: spy('request')) end describe '#as_json' do subject { entity.as_json } - it 'contains original build name' do - expect(subject[:name]).to eq 'test_build' + it 'contains original job name' do + expect(subject[:name]).to eq 'test_job' end it 'contains path to the action play' do - expect(subject[:path]).to include "builds/#{build.id}/play" + expect(subject[:path]).to include "jobs/#{job.id}/play" end it 'contains whether it is playable' do - expect(subject[:playable]).to eq build.playable? + expect(subject[:playable]).to eq job.playable? end end end diff --git a/spec/serializers/build_artifact_entity_spec.rb b/spec/serializers/build_artifact_entity_spec.rb index 2fc60aa9de6..ad0d3d3839e 100644 --- a/spec/serializers/build_artifact_entity_spec.rb +++ b/spec/serializers/build_artifact_entity_spec.rb @@ -1,22 +1,32 @@ require 'spec_helper' describe BuildArtifactEntity do - let(:build) { create(:ci_build, name: 'test:build') } + let(:job) { create(:ci_build, name: 'test:job', artifacts_expire_at: 1.hour.from_now) } let(:entity) do - described_class.new(build, request: double) + described_class.new(job, request: double) end describe '#as_json' do subject { entity.as_json } - it 'contains build name' do - expect(subject[:name]).to eq 'test:build' + it 'contains job name' do + expect(subject[:name]).to eq 'test:job' end - it 'contains path to the artifacts' do + it 'exposes information about expiration of artifacts' do + expect(subject).to include(:expired, :expire_at) + end + + it 'contains paths to the artifacts' do expect(subject[:path]) - .to include "builds/#{build.id}/artifacts/download" + .to include "jobs/#{job.id}/artifacts/download" + + expect(subject[:keep_path]) + .to include "jobs/#{job.id}/artifacts/keep" + + expect(subject[:browse_path]) + .to include "jobs/#{job.id}/artifacts/browse" end end end diff --git a/spec/serializers/build_details_entity_spec.rb b/spec/serializers/build_details_entity_spec.rb new file mode 100644 index 00000000000..e2511e8968c --- /dev/null +++ b/spec/serializers/build_details_entity_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +describe BuildDetailsEntity do + set(:user) { create(:admin) } + + it 'inherits from BuildEntity' do + expect(described_class).to be < BuildEntity + end + + describe '#as_json' do + let(:project) { create(:project, :repository) } + let!(:build) { create(:ci_build, :failed, project: project) } + let(:request) { double('request') } + let(:entity) { described_class.new(build, request: request, current_user: user, project: project) } + subject { entity.as_json } + + before do + allow(request).to receive(:current_user).and_return(user) + end + + context 'when the user has access to issues and merge requests' do + let!(:merge_request) do + create(:merge_request, source_project: project, source_branch: build.ref) + end + + before do + allow(build).to receive(:merge_request).and_return(merge_request) + end + + it 'contains the needed key value pairs' do + expect(subject).to include(:coverage, :erased_at, :duration) + expect(subject).to include(:artifacts, :runner, :pipeline) + expect(subject).to include(:raw_path, :merge_request) + expect(subject).to include(:new_issue_path) + end + + it 'exposes details of the merge request' do + expect(subject[:merge_request]).to include(:iid, :path) + end + + context 'when the build has been erased' do + let!(:build) { create(:ci_build, :erasable, project: project) } + + it 'exposes the user whom erased the build' do + expect(subject).to include(:erase_path) + end + end + + context 'when the build has been erased' do + let!(:build) { create(:ci_build, erased_at: Time.now, project: project, erased_by: user) } + + it 'exposes the user whom erased the build' do + expect(subject).to include(:erased_by) + end + end + end + + context 'when the user can only read the build' do + let(:user) { create(:user) } + + it "won't display the paths to issues and merge requests" do + expect(subject['new_issue_path']).to be_nil + expect(subject['merge_request_path']).to be_nil + end + end + end +end diff --git a/spec/serializers/build_entity_spec.rb b/spec/serializers/build_entity_spec.rb index b5eb84ae43b..46d43a80ef7 100644 --- a/spec/serializers/build_entity_spec.rb +++ b/spec/serializers/build_entity_spec.rb @@ -2,7 +2,8 @@ require 'spec_helper' describe BuildEntity do let(:user) { create(:user) } - let(:build) { create(:ci_build) } + let(:build) { create(:ci_build, :failed) } + let(:project) { build.project } let(:request) { double('request') } before do @@ -17,6 +18,7 @@ describe BuildEntity do it 'contains paths to build page and retry action' do expect(subject).to include(:build_path, :retry_path) + expect(subject[:retry_path]).not_to be_nil end it 'does not contain sensitive information' do @@ -52,7 +54,10 @@ describe BuildEntity do context 'when user is allowed to trigger action' do before do - build.project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) end it 'contains path to play action' do diff --git a/spec/serializers/merge_request_entity_spec.rb b/spec/serializers/merge_request_entity_spec.rb index b75c73e78c2..d38433c2365 100644 --- a/spec/serializers/merge_request_entity_spec.rb +++ b/spec/serializers/merge_request_entity_spec.rb @@ -26,7 +26,7 @@ describe MergeRequestEntity do pipeline = build_stubbed(:ci_pipeline) allow(resource).to receive(:head_pipeline).and_return(pipeline) - pipeline_payload = PipelineEntity + pipeline_payload = PipelineDetailsEntity .represent(pipeline, request: req) .as_json diff --git a/spec/serializers/pipeline_details_entity_spec.rb b/spec/serializers/pipeline_details_entity_spec.rb new file mode 100644 index 00000000000..03cc5ae9b63 --- /dev/null +++ b/spec/serializers/pipeline_details_entity_spec.rb @@ -0,0 +1,120 @@ +require 'spec_helper' + +describe PipelineDetailsEntity do + set(:user) { create(:user) } + let(:request) { double('request') } + + it 'inherrits from PipelineEntity' do + expect(described_class).to be < PipelineEntity + end + + before do + allow(request).to receive(:current_user).and_return(user) + end + + let(:entity) do + described_class.represent(pipeline, request: request) + end + + describe '#as_json' do + subject { entity.as_json } + + context 'when pipeline is empty' do + let(:pipeline) { create(:ci_empty_pipeline) } + + it 'contains details' do + expect(subject).to include :details + expect(subject[:details]) + .to include :duration, :finished_at + expect(subject[:details]) + .to include :stages, :artifacts, :manual_actions + expect(subject[:details][:status]).to include :icon, :favicon, :text, :label + end + + it 'contains flags' do + expect(subject).to include :flags + expect(subject[:flags]) + .to include :latest, :stuck, + :yaml_errors, :retryable, :cancelable + end + end + + context 'when pipeline is retryable' do + let(:project) { create(:empty_project) } + + let(:pipeline) do + create(:ci_pipeline, status: :success, project: project) + end + + before do + create(:ci_build, :failed, pipeline: pipeline) + end + + context 'user has ability to retry pipeline' do + before { project.team << [user, :developer] } + + it 'retryable flag is true' do + expect(subject[:flags][:retryable]).to eq true + end + end + + context 'user does not have ability to retry pipeline' do + it 'retryable flag is false' do + expect(subject[:flags][:retryable]).to eq false + end + end + end + + context 'when pipeline is cancelable' do + let(:project) { create(:empty_project) } + + let(:pipeline) do + create(:ci_pipeline, status: :running, project: project) + end + + before do + create(:ci_build, :pending, pipeline: pipeline) + end + + context 'user has ability to cancel pipeline' do + before { project.add_developer(user) } + + it 'cancelable flag is true' do + expect(subject[:flags][:cancelable]).to eq true + end + end + + context 'user does not have ability to cancel pipeline' do + it 'cancelable flag is false' do + expect(subject[:flags][:cancelable]).to eq false + end + end + end + + context 'when pipeline has YAML errors' do + let(:pipeline) do + create(:ci_pipeline, config: { rspec: { invalid: :value } }) + end + + it 'contains information about error' do + expect(subject[:yaml_errors]).to be_present + end + + it 'contains flag that indicates there are errors' do + expect(subject[:flags][:yaml_errors]).to be true + end + end + + context 'when pipeline does not have YAML errors' do + let(:pipeline) { create(:ci_empty_pipeline) } + + it 'does not contain field that normally holds an error' do + expect(subject).not_to have_key(:yaml_errors) + end + + it 'contains flag that indicates there are no errors' do + expect(subject[:flags][:yaml_errors]).to be false + end + end + end +end diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb index d2482ac434b..a059c2cc736 100644 --- a/spec/serializers/pipeline_entity_spec.rb +++ b/spec/serializers/pipeline_entity_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe PipelineEntity do - let(:user) { create(:user) } + set(:user) { create(:user) } let(:request) { double('request') } before do @@ -19,7 +19,7 @@ describe PipelineEntity do let(:pipeline) { create(:ci_empty_pipeline) } it 'contains required fields' do - expect(subject).to include :id, :user, :path, :coverage + expect(subject).to include :id, :user, :path, :coverage, :source expect(subject).to include :ref, :commit expect(subject).to include :updated_at, :created_at end @@ -28,15 +28,13 @@ describe PipelineEntity do expect(subject).to include :details expect(subject[:details]) .to include :duration, :finished_at - expect(subject[:details]) - .to include :stages, :artifacts, :manual_actions expect(subject[:details][:status]).to include :icon, :favicon, :text, :label end it 'contains flags' do expect(subject).to include :flags expect(subject[:flags]) - .to include :latest, :triggered, :stuck, + .to include :latest, :stuck, :yaml_errors, :retryable, :cancelable end end @@ -55,20 +53,12 @@ describe PipelineEntity do context 'user has ability to retry pipeline' do before { project.team << [user, :developer] } - it 'retryable flag is true' do - expect(subject[:flags][:retryable]).to eq true - end - it 'contains retry path' do expect(subject[:retry_path]).to be_present end end context 'user does not have ability to retry pipeline' do - it 'retryable flag is false' do - expect(subject[:flags][:retryable]).to eq false - end - it 'does not contain retry path' do expect(subject).not_to have_key(:retry_path) end @@ -87,11 +77,7 @@ describe PipelineEntity do end context 'user has ability to cancel pipeline' do - before { project.team << [user, :developer] } - - it 'cancelable flag is true' do - expect(subject[:flags][:cancelable]).to eq true - end + before { project.add_developer(user) } it 'contains cancel path' do expect(subject[:cancel_path]).to be_present @@ -99,42 +85,12 @@ describe PipelineEntity do end context 'user does not have ability to cancel pipeline' do - it 'cancelable flag is false' do - expect(subject[:flags][:cancelable]).to eq false - end - it 'does not contain cancel path' do expect(subject).not_to have_key(:cancel_path) end end end - context 'when pipeline has YAML errors' do - let(:pipeline) do - create(:ci_pipeline, config: { rspec: { invalid: :value } }) - end - - it 'contains flag that indicates there are errors' do - expect(subject[:flags][:yaml_errors]).to be true - end - - it 'contains information about error' do - expect(subject[:yaml_errors]).to be_present - end - end - - context 'when pipeline does not have YAML errors' do - let(:pipeline) { create(:ci_empty_pipeline) } - - it 'contains flag that indicates there are no errors' do - expect(subject[:flags][:yaml_errors]).to be false - end - - it 'does not contain field that normally holds an error' do - expect(subject).not_to have_key(:yaml_errors) - end - end - context 'when pipeline ref is empty' do let(:pipeline) { create(:ci_empty_pipeline) } diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb index f2426db6d81..088f24eb180 100644 --- a/spec/serializers/pipeline_serializer_spec.rb +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -113,7 +113,7 @@ describe PipelineSerializer do it "verifies number of queries" do recorded = ActiveRecord::QueryRecorder.new { subject } - expect(recorded.count).to be_within(1).of(58) + expect(recorded.count).to be_within(1).of(60) expect(recorded.cached_count).to eq(0) end diff --git a/spec/serializers/runner_entity_spec.rb b/spec/serializers/runner_entity_spec.rb new file mode 100644 index 00000000000..4f25a8dcfa0 --- /dev/null +++ b/spec/serializers/runner_entity_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe RunnerEntity do + let(:runner) { create(:ci_runner, :specific) } + let(:entity) { described_class.new(runner, request: request, current_user: user) } + let(:request) { double('request') } + let(:project) { create(:empty_project) } + let(:user) { create(:admin) } + + before do + allow(request).to receive(:current_user).and_return(user) + allow(request).to receive(:project).and_return(project) + end + + describe '#as_json' do + subject { entity.as_json } + + it 'contains required fields' do + expect(subject).to include(:id, :description) + expect(subject).to include(:edit_path) + end + end +end diff --git a/spec/serializers/user_entity_spec.rb b/spec/serializers/user_entity_spec.rb index c5d11cbcf5e..cd778e49107 100644 --- a/spec/serializers/user_entity_spec.rb +++ b/spec/serializers/user_entity_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe UserEntity do + include Gitlab::Routing + let(:entity) { described_class.new(user) } let(:user) { create(:user) } subject { entity.as_json } @@ -20,4 +22,8 @@ describe UserEntity do it 'does not expose 2FA OTPs' do expect(subject).not_to include(/otp/) end + + it 'exposes user path' do + expect(subject[:path]).to eq user_path(user) + end end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index b536103ed65..597c3947e71 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -9,13 +9,13 @@ describe Ci::CreatePipelineService, services: true do end describe '#execute' do - def execute_service(after: project.commit.id, message: 'Message', ref: 'refs/heads/master') + def execute_service(source: :push, after: project.commit.id, message: 'Message', ref: 'refs/heads/master') params = { ref: ref, before: '00000000', after: after, commits: [{ message: message }] } - described_class.new(project, user, params).execute + described_class.new(project, user, params).execute(source) end context 'valid params' do @@ -30,13 +30,14 @@ describe Ci::CreatePipelineService, services: true do it 'creates a pipeline' do expect(pipeline).to be_kind_of(Ci::Pipeline) expect(pipeline).to be_valid + expect(pipeline).to be_push expect(pipeline).to eq(project.pipelines.last) expect(pipeline).to have_attributes(user: user) expect(pipeline).to have_attributes(status: 'pending') expect(pipeline.builds.first).to be_kind_of(Ci::Build) end - context '#update_merge_requests_head_pipeline' do + 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 +59,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 +71,18 @@ describe Ci::CreatePipelineService, services: true do expect(merge_request.reload.head_pipeline).to eq(head_pipeline) end end + + context 'when the pipeline is not the latest for the branch' 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(Ci::Pipeline).to receive(:latest?).and_return(false) + + pipeline + + expect(merge_request.reload.head_pipeline).to be_nil + end + end end context 'auto-cancel enabled' do @@ -283,5 +296,20 @@ describe Ci::CreatePipelineService, services: true do expect(Environment.find_by(name: "review/master")).not_to be_nil end end + + context 'when environment with invalid name' do + before do + config = YAML.dump(deploy: { environment: { name: 'name,with,commas' }, script: 'ls' }) + stub_ci_pipeline_yaml_file(config) + end + + it 'does not create an environment' do + expect do + result = execute_service + + expect(result).to be_persisted + end.not_to change { Environment.count } + end + end end end diff --git a/spec/services/ci/create_trigger_request_service_spec.rb b/spec/services/ci/create_trigger_request_service_spec.rb index 5a20102872a..f2956262f4b 100644 --- a/spec/services/ci/create_trigger_request_service_spec.rb +++ b/spec/services/ci/create_trigger_request_service_spec.rb @@ -16,6 +16,7 @@ describe Ci::CreateTriggerRequestService, services: true do context 'without owner' do it { expect(subject).to be_kind_of(Ci::TriggerRequest) } it { expect(subject.pipeline).to be_kind_of(Ci::Pipeline) } + it { expect(subject.pipeline).to be_trigger } it { expect(subject.builds.first).to be_kind_of(Ci::Build) } end @@ -25,6 +26,7 @@ describe Ci::CreateTriggerRequestService, services: true do it { expect(subject).to be_kind_of(Ci::TriggerRequest) } it { expect(subject.pipeline).to be_kind_of(Ci::Pipeline) } + it { expect(subject.pipeline).to be_trigger } it { expect(subject.pipeline.user).to eq(owner) } it { expect(subject.builds.first).to be_kind_of(Ci::Build) } it { expect(subject.builds.first.user).to eq(owner) } diff --git a/spec/services/ci/play_build_service_spec.rb b/spec/services/ci/play_build_service_spec.rb index d6f9fa42045..ea211de1f82 100644 --- a/spec/services/ci/play_build_service_spec.rb +++ b/spec/services/ci/play_build_service_spec.rb @@ -13,8 +13,11 @@ describe Ci::PlayBuildService, '#execute', :services do context 'when project does not have repository yet' do let(:project) { create(:empty_project) } - it 'allows user with master role to play build' do - project.add_master(user) + it 'allows user to play build if protected branch rules are met' do + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) service.execute(build) @@ -45,7 +48,10 @@ describe Ci::PlayBuildService, '#execute', :services do let(:build) { create(:ci_build, :manual, pipeline: pipeline) } before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) end it 'enqueues the build' do @@ -64,7 +70,10 @@ describe Ci::PlayBuildService, '#execute', :services do let(:build) { create(:ci_build, when: :manual, pipeline: pipeline) } before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: build.ref, project: project) end it 'duplicates the build' do diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index fc5de5d069a..1557cb3c938 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -333,10 +333,11 @@ describe Ci::ProcessPipelineService, '#execute', :services do context 'when pipeline is promoted sequentially up to the end' do before do - # We are using create(:empty_project), and users has to be master in - # order to execute manual action when repository does not exist. + # Users need ability to merge into a branch in order to trigger + # protected manual actions. # - project.add_master(user) + create(:protected_branch, :developers_can_merge, + name: 'master', project: project) end it 'properly processes entire pipeline' do diff --git a/spec/services/ci/retry_pipeline_service_spec.rb b/spec/services/ci/retry_pipeline_service_spec.rb index d941d56c0d8..3e860203063 100644 --- a/spec/services/ci/retry_pipeline_service_spec.rb +++ b/spec/services/ci/retry_pipeline_service_spec.rb @@ -6,9 +6,12 @@ describe Ci::RetryPipelineService, '#execute', :services do let(:pipeline) { create(:ci_pipeline, project: project) } let(:service) { described_class.new(project, user) } - context 'when user has ability to modify pipeline' do + context 'when user has full ability to modify pipeline' do before do - project.add_master(user) + project.add_developer(user) + + create(:protected_branch, :developers_can_merge, + name: pipeline.ref, project: project) end context 'when there are already retried jobs present' do diff --git a/spec/services/create_deployment_service_spec.rb b/spec/services/create_deployment_service_spec.rb index f35d7a33548..5398b5c3f7e 100644 --- a/spec/services/create_deployment_service_spec.rb +++ b/spec/services/create_deployment_service_spec.rb @@ -1,156 +1,117 @@ require 'spec_helper' describe CreateDeploymentService, services: true do - let(:project) { create(:empty_project) } let(:user) { create(:user) } + let(:options) { nil } + + let(:job) do + create(:ci_build, + ref: 'master', + tag: false, + environment: 'production', + options: { environment: options }) + end - let(:service) { described_class.new(project, user, params) } + let(:project) { job.project } - describe '#execute' do - let(:options) { nil } - let(:params) do - { - environment: 'production', - ref: 'master', - tag: false, - sha: '97de212e80737a608d939f648d959671fb0a0142', - options: options - } - end + let!(:environment) do + create(:environment, project: project, name: 'production') + end - subject { service.execute } + let(:service) { described_class.new(job) } - context 'when no environments exist' do - it 'does create a new environment' do - expect { subject }.to change { Environment.count }.by(1) - end + describe '#execute' do + subject { service.execute } - it 'does create a deployment' do + context 'when environment exists' do + it 'creates a deployment' do expect(subject).to be_persisted end end - context 'when environment exist' do - let!(:environment) { create(:environment, project: project, name: 'production') } - - it 'does not create a new environment' do - expect { subject }.not_to change { Environment.count } - end + context 'when environment does not exist' do + let(:environment) {} - it 'does create a deployment' do - expect(subject).to be_persisted + it 'does not create a deployment' do + expect do + expect(subject).to be_nil + end.not_to change { Deployment.count } end + end - context 'and start action is defined' do - let(:options) { { action: 'start' } } + context 'when start action is defined' do + let(:options) { { action: 'start' } } - context 'and environment is stopped' do - before do - environment.stop - end + context 'and environment is stopped' do + before do + environment.stop + end - it 'makes environment available' do - subject + it 'makes environment available' do + subject - expect(environment.reload).to be_available - end + expect(environment.reload).to be_available + end - it 'does create a deployment' do - expect(subject).to be_persisted - end + it 'creates a deployment' do + expect(subject).to be_persisted end end + end - context 'and stop action is defined' do - let(:options) { { action: 'stop' } } - - context 'and environment is available' do - before do - environment.start - end - - it 'makes environment stopped' do - subject - - expect(environment.reload).to be_stopped - end + context 'when stop action is defined' do + let(:options) { { action: 'stop' } } - it 'does not create a deployment' do - expect(subject).to be_nil - end + context 'and environment is available' do + before do + environment.start end - end - end - context 'for environment with invalid name' do - let(:params) do - { - environment: 'name,with,commas', - ref: 'master', - tag: false, - sha: '97de212e80737a608d939f648d959671fb0a0142' - } - end + it 'makes environment stopped' do + subject - it 'does not create a new environment' do - expect { subject }.not_to change { Environment.count } - end + expect(environment.reload).to be_stopped + end - it 'does not create a deployment' do - expect(subject).to be_nil + it 'does not create a deployment' do + expect(subject).to be_nil + end end end context 'when variables are used' do - let(:params) do - { - environment: 'review-apps/$CI_COMMIT_REF_NAME', - ref: 'master', - tag: false, - sha: '97de212e80737a608d939f648d959671fb0a0142', - options: { - name: 'review-apps/$CI_COMMIT_REF_NAME', - url: 'http://$CI_COMMIT_REF_NAME.review-apps.gitlab.com' - }, - variables: [ - { key: 'CI_COMMIT_REF_NAME', value: 'feature-review-apps' } - ] - } + let(:options) do + { name: 'review-apps/$CI_COMMIT_REF_NAME', + url: 'http://$CI_COMMIT_REF_NAME.review-apps.gitlab.com' } end - it 'does create a new environment' do - expect { subject }.to change { Environment.count }.by(1) - - expect(subject.environment.name).to eq('review-apps/feature-review-apps') - expect(subject.environment.external_url).to eq('http://feature-review-apps.review-apps.gitlab.com') + before do + environment.update(name: 'review-apps/master') + job.update(environment: 'review-apps/$CI_COMMIT_REF_NAME') end - it 'does create a new deployment' do + it 'creates a new deployment' do expect(subject).to be_persisted end - context 'and environment exist' do - let!(:environment) { create(:environment, project: project, name: 'review-apps/feature-review-apps') } - - it 'does not create a new environment' do - expect { subject }.not_to change { Environment.count } - end - - it 'updates external url' do - subject + it 'does not create a new environment' do + expect { subject }.not_to change { Environment.count } + end - expect(subject.environment.name).to eq('review-apps/feature-review-apps') - expect(subject.environment.external_url).to eq('http://feature-review-apps.review-apps.gitlab.com') - end + it 'updates external url' do + subject - it 'does create a new deployment' do - expect(subject).to be_persisted - end + expect(subject.environment.name).to eq('review-apps/master') + expect(subject.environment.external_url).to eq('http://master.review-apps.gitlab.com') end end context 'when project was removed' do - let(:project) { nil } + let(:environment) {} + + before do + job.update(project: nil) + end it 'does not create deployment or environment' do expect { subject }.not_to raise_error @@ -162,34 +123,26 @@ describe CreateDeploymentService, services: true do end describe 'processing of builds' do - let(:environment) { nil } - - shared_examples 'does not create environment and deployment' do - it 'does not create a new environment' do - expect { subject }.not_to change { Environment.count } - end - + shared_examples 'does not create deployment' do it 'does not create a new deployment' do expect { subject }.not_to change { Deployment.count } end it 'does not call a service' do expect_any_instance_of(described_class).not_to receive(:execute) + subject end end - shared_examples 'does create environment and deployment' do - it 'does create a new environment' do - expect { subject }.to change { Environment.count }.by(1) - end - - it 'does create a new deployment' do + shared_examples 'creates deployment' do + it 'creates a new deployment' do expect { subject }.to change { Deployment.count }.by(1) end - it 'does call a service' do + it 'calls a service' do expect_any_instance_of(described_class).to receive(:execute) + subject end @@ -199,7 +152,7 @@ describe CreateDeploymentService, services: true do expect(Deployment.last.deployable).to eq(deployable) end - it 'create environment has URL set' do + it 'updates environment URL' do subject expect(Deployment.last.environment.external_url).not_to be_nil @@ -207,41 +160,39 @@ describe CreateDeploymentService, services: true do end context 'without environment specified' do - let(:build) { create(:ci_build, project: project) } + let(:job) { create(:ci_build) } - it_behaves_like 'does not create environment and deployment' do - subject { build.success } + it_behaves_like 'does not create deployment' do + subject { job.success } end end context 'when environment is specified' do - let(:pipeline) { create(:ci_pipeline, project: project) } - let(:build) { create(:ci_build, pipeline: pipeline, environment: 'production', options: options) } + let(:deployable) { job } + let(:options) do { environment: { name: 'production', url: 'http://gitlab.com' } } end - context 'when build succeeds' do - it_behaves_like 'does create environment and deployment' do - let(:deployable) { build } - - subject { build.success } + context 'when job succeeds' do + it_behaves_like 'creates deployment' do + subject { job.success } end end - context 'when build fails' do - it_behaves_like 'does not create environment and deployment' do - subject { build.drop } + context 'when job fails' do + it_behaves_like 'does not create deployment' do + subject { job.drop } end end - context 'when build is retried' do - it_behaves_like 'does create environment and deployment' do + context 'when job is retried' do + it_behaves_like 'creates deployment' do before do project.add_developer(user) end - let(:deployable) { Ci::Build.retry(build, user) } + let(:deployable) { Ci::Build.retry(job, user) } subject { deployable.success } end @@ -250,15 +201,6 @@ describe CreateDeploymentService, services: true do end describe "merge request metrics" do - let(:params) do - { - environment: 'production', - ref: 'master', - tag: false, - sha: '97de212e80737a608d939f648d959671fb0a0142b' - } - end - let(:merge_request) { create(:merge_request, target_branch: 'master', source_branch: 'feature', source_project: project) } context "while updating the 'first_deployed_to_production_at' time" do @@ -273,8 +215,8 @@ describe CreateDeploymentService, services: true do end it "doesn't set the time if the deploy's environment is not 'production'" do - staging_params = params.merge(environment: 'staging') - service = described_class.new(project, user, staging_params) + job.update(environment: 'staging') + service = described_class.new(job) service.execute expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil @@ -298,7 +240,7 @@ describe CreateDeploymentService, services: true do expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_like_time(time) # Current deploy - service = described_class.new(project, user, params) + service = described_class.new(job) Timecop.freeze(time + 12.hours) { service.execute } expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_like_time(time) @@ -318,7 +260,7 @@ describe CreateDeploymentService, services: true do expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil # Current deploy - service = described_class.new(project, user, params) + service = described_class.new(job) Timecop.freeze(time + 12.hours) { service.execute } expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil diff --git a/spec/services/notes/diff_position_update_service_spec.rb b/spec/services/discussions/update_diff_position_service_spec.rb index 380c296fd3a..177e32e13bd 100644 --- a/spec/services/notes/diff_position_update_service_spec.rb +++ b/spec/services/discussions/update_diff_position_service_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Notes::DiffPositionUpdateService, services: true do +describe Discussions::UpdateDiffPositionService, services: true do let(:project) { create(:project, :repository) } let(:current_user) { project.owner } let(:create_commit) { project.commit("913c66a37b4a45b9769037c55c2d238bd0942d2e") } @@ -138,7 +138,7 @@ describe Notes::DiffPositionUpdateService, services: true do # .. .. describe "#execute" do - let(:note) { create(:diff_note_on_merge_request, project: project, position: old_position) } + let(:discussion) { create(:diff_note_on_merge_request, project: project, position: old_position).to_discussion } let(:old_position) do Gitlab::Diff::Position.new( @@ -154,11 +154,11 @@ describe Notes::DiffPositionUpdateService, services: true do let(:line) { 16 } it "updates the position" do - subject.execute(note) + subject.execute(discussion) - expect(note.original_position).to eq(old_position) - expect(note.position).not_to eq(old_position) - expect(note.position.new_line).to eq(22) + expect(discussion.original_position).to eq(old_position) + expect(discussion.position).not_to eq(old_position) + expect(discussion.position.new_line).to eq(22) end end @@ -166,27 +166,27 @@ describe Notes::DiffPositionUpdateService, services: true do let(:line) { 9 } it "doesn't update the position" do - subject.execute(note) + subject.execute(discussion) - expect(note.original_position).to eq(old_position) - expect(note.position).to eq(old_position) + expect(discussion.original_position).to eq(old_position) + expect(discussion.position).to eq(old_position) end it 'sets the change position' do - subject.execute(note) + subject.execute(discussion) - change_position = note.change_position + change_position = discussion.change_position expect(change_position.start_sha).to eq(old_diff_refs.head_sha) expect(change_position.head_sha).to eq(new_diff_refs.head_sha) expect(change_position.old_line).to eq(9) expect(change_position.new_line).to be_nil end - it 'creates a system note' do + it 'creates a system discussion' do expect(SystemNoteService).to receive(:diff_discussion_outdated).with( - note.to_discussion, project, current_user, instance_of(Gitlab::Diff::Position)) + discussion, project, current_user, instance_of(Gitlab::Diff::Position)) - subject.execute(note) + subject.execute(discussion) end end end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index ab06f45dbb9..bcd1fb64ab9 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -131,6 +131,19 @@ describe GitPushService, services: true do end end + describe "Pipelines" do + subject { execute_service(project, user, @oldrev, @newrev, @ref) } + + before do + stub_ci_pipeline_to_return_yaml_file + end + + it "creates a new pipeline" do + expect{ subject }.to change{ Ci::Pipeline.count } + expect(Ci::Pipeline.last).to be_push + end + end + describe "Push Event" do before do service = execute_service(project, user, @oldrev, @newrev, @ref ) @@ -436,6 +449,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/git_tag_push_service_spec.rb b/spec/services/git_tag_push_service_spec.rb index b73beb3f6fc..1fdcb420a8b 100644 --- a/spec/services/git_tag_push_service_spec.rb +++ b/spec/services/git_tag_push_service_spec.rb @@ -30,6 +30,20 @@ describe GitTagPushService, services: true do end end + describe "Pipelines" do + subject { service.execute } + + before do + stub_ci_pipeline_to_return_yaml_file + project.team << [user, :developer] + end + + it "creates a new pipeline" do + expect{ subject }.to change{ Ci::Pipeline.count } + expect(Ci::Pipeline.last).to be_push + end + end + describe "Git Tag Push Data" do subject { @push_data } let(:tag) { project.repository.find_tag(tag_name) } diff --git a/spec/services/gravatar_service_spec.rb b/spec/services/gravatar_service_spec.rb new file mode 100644 index 00000000000..8c4ad8c7a3e --- /dev/null +++ b/spec/services/gravatar_service_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe GravatarService, service: true do + describe '#execute' do + let(:url) { 'http://example.com/avatar?hash=%{hash}&size=%{size}&email=%{email}&username=%{username}' } + + before do + allow(Gitlab.config.gravatar).to receive(:plain_url).and_return(url) + end + + it 'replaces the placeholders' do + avatar_url = described_class.new.execute('user@example.com', 100, 2, username: 'user') + + expect(avatar_url).to include("hash=#{Digest::MD5.hexdigest('user@example.com')}") + expect(avatar_url).to include("size=200") + expect(avatar_url).to include("email=user%40example.com") + expect(avatar_url).to include("username=user") + end + end +end diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb index 0a1f41719f7..be0e829880e 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 93a8270fd16..391ecad303a 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 8a6732faa19..f99b11f208c 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 d55a7657c0e..154f30aac3b 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/conflicts/resolve_service_spec.rb b/spec/services/merge_requests/conflicts/resolve_service_spec.rb index 19e8d5cc5f1..c77e6e9cd50 100644 --- a/spec/services/merge_requests/conflicts/resolve_service_spec.rb +++ b/spec/services/merge_requests/conflicts/resolve_service_spec.rb @@ -26,6 +26,10 @@ describe MergeRequests::Conflicts::ResolveService do describe '#execute' do let(:service) { described_class.new(merge_request) } + def blob_content(project, ref, path) + project.repository.blob_at(ref, path).data + end + context 'with section params' do let(:params) do { @@ -66,6 +70,35 @@ describe MergeRequests::Conflicts::ResolveService do end end + context 'when some files have trailing newlines' do + let!(:source_head) do + branch = 'conflict-resolvable' + path = 'files/ruby/popen.rb' + popen_content = blob_content(project, branch, path) + + project.repository.update_file( + user, + path, + popen_content.chomp("\n"), + message: 'Remove trailing newline from popen.rb', + branch_name: branch + ) + end + + before do + service.execute(user, params) + end + + it 'preserves trailing newlines from our side of the conflicts' do + head_sha = merge_request.source_branch_head.sha + popen_content = blob_content(project, head_sha, 'files/ruby/popen.rb') + regex_content = blob_content(project, head_sha, 'files/ruby/regex.rb') + + expect(popen_content).not_to end_with("\n") + expect(regex_content).to end_with("\n") + end + end + context 'when the source project is a fork and does not contain the HEAD of the target branch' do let!(:target_head) do project.repository.create_file( @@ -142,10 +175,13 @@ describe MergeRequests::Conflicts::ResolveService do end it 'sets the content to the content given' do - blob = merge_request.source_project.repository.blob_at(merge_request.source_branch_head.sha, - 'files/ruby/popen.rb') + blob = blob_content( + merge_request.source_project, + merge_request.source_branch_head.sha, + 'files/ruby/popen.rb' + ) - expect(blob.data).to eq(popen_content) + expect(blob).to eq(popen_content) end end diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index b70e9d534a4..2963f62cc7d 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 00000000000..a20b32eda5f --- /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 a99d4eac9bd..b6d4db2f922 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 4b8589b2736..0d6dd28e332 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/projects/import_service_spec.rb b/spec/services/projects/import_service_spec.rb index 852a4ac852f..44db299812f 100644 --- a/spec/services/projects/import_service_spec.rb +++ b/spec/services/projects/import_service_spec.rb @@ -186,7 +186,7 @@ describe Projects::ImportService, services: true do } ) - allow(Gitlab.config.omniauth).to receive(:providers).and_return([provider]) + stub_omniauth_setting(providers: [provider]) end end end diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb index 2112f1cf9ea..5cf989105d0 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/submit_usage_ping_service_spec.rb b/spec/services/submit_usage_ping_service_spec.rb new file mode 100644 index 00000000000..63a1e78f274 --- /dev/null +++ b/spec/services/submit_usage_ping_service_spec.rb @@ -0,0 +1,101 @@ +require 'spec_helper' + +describe SubmitUsagePingService do + context 'when usage ping is disabled' do + before do + stub_application_setting(usage_ping_enabled: false) + end + + it 'does not run' do + expect(HTTParty).not_to receive(:post) + + result = subject.execute + + expect(result).to eq false + end + end + + context 'when usage ping is enabled' do + before do + stub_application_setting(usage_ping_enabled: true) + end + + it 'sends a POST request' do + response = stub_response(without_conv_index_params) + + subject.execute + + expect(response).to have_been_requested + end + + it 'refreshes usage data statistics before submitting' do + stub_response(without_conv_index_params) + + expect(Gitlab::UsageData).to receive(:to_json) + .with(force_refresh: true) + .and_call_original + + subject.execute + end + + it 'saves conversational development index data from the response' do + stub_response(with_conv_index_params) + + expect { subject.execute } + .to change { ConversationalDevelopmentIndex::Metric.count } + .by(1) + + expect(ConversationalDevelopmentIndex::Metric.last.leader_issues).to eq 10.2 + end + end + + def without_conv_index_params + { + conv_index: {} + } + end + + def with_conv_index_params + { + conv_index: { + leader_issues: 10.2, + instance_issues: 3.2, + + leader_notes: 25.3, + instance_notes: 23.2, + + leader_milestones: 16.2, + instance_milestones: 5.5, + + leader_boards: 5.2, + instance_boards: 3.2, + + leader_merge_requests: 5.2, + instance_merge_requests: 3.2, + + leader_ci_pipelines: 25.1, + instance_ci_pipelines: 21.3, + + leader_environments: 3.3, + instance_environments: 2.2, + + leader_deployments: 41.3, + instance_deployments: 15.2, + + leader_projects_prometheus_active: 0.31, + instance_projects_prometheus_active: 0.30, + + leader_service_desk_issues: 15.8, + instance_service_desk_issues: 15.1 + } + } + end + + def stub_response(body) + stub_request(:post, 'https://version.gitlab.com/usage_data'). + to_return( + headers: { 'Content-Type' => 'application/json' }, + body: body.to_json + ) + end +end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 5a7cfaff7fb..c499b1bb343 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/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb index de37a61e388..5409f67c091 100644 --- a/spec/services/users/destroy_service_spec.rb +++ b/spec/services/users/destroy_service_spec.rb @@ -147,16 +147,22 @@ describe Users::DestroyService, services: true do end context "migrating associated records" do + let!(:issue) { create(:issue, author: user) } + it 'delegates to the `MigrateToGhostUser` service to move associated records to the ghost user' do - expect_any_instance_of(Users::MigrateToGhostUserService).to receive(:execute).once + expect_any_instance_of(Users::MigrateToGhostUserService).to receive(:execute).once.and_call_original service.execute(user) + + expect(issue.reload.author).to be_ghost end it 'does not run `MigrateToGhostUser` if hard_delete option is given' do expect_any_instance_of(Users::MigrateToGhostUserService).not_to receive(:execute) service.execute(user, hard_delete: true) + + expect(Issue.exists?(issue.id)).to be_falsy end end end diff --git a/spec/services/users/refresh_authorized_projects_service_spec.rb b/spec/services/users/refresh_authorized_projects_service_spec.rb index b19374ef1a2..8c40d25e00c 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/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb new file mode 100644 index 00000000000..b5abc46e80c --- /dev/null +++ b/spec/services/web_hook_service_spec.rb @@ -0,0 +1,137 @@ +require 'spec_helper' + +describe WebHookService, services: true do + let(:project) { create(:empty_project) } + let(:project_hook) { create(:project_hook) } + let(:headers) do + { + 'Content-Type' => 'application/json', + 'X-Gitlab-Event' => 'Push Hook' + } + end + let(:data) do + { before: 'oldrev', after: 'newrev', ref: 'ref' } + end + let(:service_instance) { WebHookService.new(project_hook, data, 'push_hooks') } + + describe '#execute' do + before(:each) do + project.hooks << [project_hook] + + WebMock.stub_request(:post, project_hook.url) + end + + context 'when token is defined' do + let(:project_hook) { create(:project_hook, :token) } + + it 'POSTs to the webhook URL' do + service_instance.execute + expect(WebMock).to have_requested(:post, project_hook.url).with( + headers: headers.merge({ 'X-Gitlab-Token' => project_hook.token }) + ).once + end + end + + it 'POSTs to the webhook URL' do + service_instance.execute + expect(WebMock).to have_requested(:post, project_hook.url).with( + headers: headers + ).once + end + + it 'POSTs the data as JSON' do + service_instance.execute + expect(WebMock).to have_requested(:post, project_hook.url).with( + headers: headers + ).once + end + + it 'catches exceptions' do + WebMock.stub_request(:post, project_hook.url).to_raise(StandardError.new('Some error')) + + expect { service_instance.execute }.to raise_error(StandardError) + end + + it 'handles exceptions' do + exceptions = [SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Net::OpenTimeout] + exceptions.each do |exception_class| + exception = exception_class.new('Exception message') + + WebMock.stub_request(:post, project_hook.url).to_raise(exception) + expect(service_instance.execute).to eq([nil, exception.message]) + expect { service_instance.execute }.not_to raise_error + end + end + + it 'handles 200 status code' do + WebMock.stub_request(:post, project_hook.url).to_return(status: 200, body: 'Success') + + expect(service_instance.execute).to eq([200, 'Success']) + end + + it 'handles 2xx status codes' do + WebMock.stub_request(:post, project_hook.url).to_return(status: 201, body: 'Success') + + expect(service_instance.execute).to eq([201, 'Success']) + end + + context 'execution logging' do + let(:hook_log) { project_hook.web_hook_logs.last } + + context 'with success' do + before do + WebMock.stub_request(:post, project_hook.url).to_return(status: 200, body: 'Success') + service_instance.execute + end + + it 'log successful execution' do + expect(hook_log.trigger).to eq('push_hooks') + expect(hook_log.url).to eq(project_hook.url) + expect(hook_log.request_headers).to eq(headers) + expect(hook_log.response_body).to eq('Success') + expect(hook_log.response_status).to eq('200') + expect(hook_log.execution_duration).to be > 0 + expect(hook_log.internal_error_message).to be_nil + end + end + + context 'with exception' do + before do + WebMock.stub_request(:post, project_hook.url).to_raise(SocketError.new('Some HTTP Post error')) + service_instance.execute + end + + it 'log failed execution' do + expect(hook_log.trigger).to eq('push_hooks') + expect(hook_log.url).to eq(project_hook.url) + expect(hook_log.request_headers).to eq(headers) + expect(hook_log.response_body).to eq('') + expect(hook_log.response_status).to eq('internal error') + expect(hook_log.execution_duration).to be > 0 + expect(hook_log.internal_error_message).to eq('Some HTTP Post error') + end + end + + context 'should not log ServiceHooks' do + let(:service_hook) { create(:service_hook) } + let(:service_instance) { WebHookService.new(service_hook, data, 'service_hook') } + + before do + WebMock.stub_request(:post, service_hook.url).to_return(status: 200, body: 'Success') + end + + it { expect { service_instance.execute }.not_to change(WebHookLog, :count) } + end + end + end + + describe '#async_execute' do + let(:system_hook) { create(:system_hook) } + + it 'enqueue WebHookWorker' do + expect(Sidekiq::Client).to receive(:enqueue).with(WebHookWorker, project_hook.id, data, 'push_hooks') + + WebHookService.new(project_hook, data, 'push_hooks').async_execute + end + end +end diff --git a/spec/services/wiki_pages/create_service_spec.rb b/spec/services/wiki_pages/create_service_spec.rb index 5341ba3d261..054e28ae7b0 100644 --- a/spec/services/wiki_pages/create_service_spec.rb +++ b/spec/services/wiki_pages/create_service_spec.rb @@ -3,6 +3,7 @@ require 'spec_helper' describe WikiPages::CreateService, services: true do let(:project) { create(:empty_project) } let(:user) { create(:user) } + let(:opts) do { title: 'Title', @@ -10,27 +11,28 @@ describe WikiPages::CreateService, services: true do format: 'markdown' } end - let(:service) { described_class.new(project, user, opts) } + + subject(:service) { described_class.new(project, user, opts) } + + before do + project.add_developer(user) + end describe '#execute' do - context "valid params" do - before do - allow(service).to receive(:execute_hooks) - project.add_master(user) - end - - subject { service.execute } - - it 'creates a valid wiki page' do - is_expected.to be_valid - expect(subject.title).to eq(opts[:title]) - expect(subject.content).to eq(opts[:content]) - expect(subject.format).to eq(opts[:format].to_sym) - end - - it 'executes webhooks' do - expect(service).to have_received(:execute_hooks).once.with(subject, 'create') - end + it 'creates wiki page with valid attributes' do + page = service.execute + + expect(page).to be_valid + expect(page.title).to eq(opts[:title]) + expect(page.content).to eq(opts[:content]) + expect(page.format).to eq(opts[:format].to_sym) + end + + it 'executes webhooks' do + expect(service).to receive(:execute_hooks).once + .with(instance_of(WikiPage), 'create') + + service.execute end end end diff --git a/spec/services/wiki_pages/destroy_service_spec.rb b/spec/services/wiki_pages/destroy_service_spec.rb index a4b9a390fe2..920be4d4c8a 100644 --- a/spec/services/wiki_pages/destroy_service_spec.rb +++ b/spec/services/wiki_pages/destroy_service_spec.rb @@ -3,19 +3,20 @@ require 'spec_helper' describe WikiPages::DestroyService, services: true do let(:project) { create(:empty_project) } let(:user) { create(:user) } - let(:wiki_page) { create(:wiki_page) } - let(:service) { described_class.new(project, user) } + let(:page) { create(:wiki_page) } - describe '#execute' do - before do - allow(service).to receive(:execute_hooks) - project.add_master(user) - end + subject(:service) { described_class.new(project, user) } + before do + project.add_developer(user) + end + + describe '#execute' do it 'executes webhooks' do - service.execute(wiki_page) + expect(service).to receive(:execute_hooks).once + .with(instance_of(WikiPage), 'delete') - expect(service).to have_received(:execute_hooks).once.with(wiki_page, 'delete') + service.execute(page) end end end diff --git a/spec/services/wiki_pages/update_service_spec.rb b/spec/services/wiki_pages/update_service_spec.rb index 2bccca764d7..5e36ea4cf94 100644 --- a/spec/services/wiki_pages/update_service_spec.rb +++ b/spec/services/wiki_pages/update_service_spec.rb @@ -3,7 +3,8 @@ require 'spec_helper' describe WikiPages::UpdateService, services: true do let(:project) { create(:empty_project) } let(:user) { create(:user) } - let(:wiki_page) { create(:wiki_page) } + let(:page) { create(:wiki_page) } + let(:opts) do { content: 'New content for wiki page', @@ -11,27 +12,28 @@ describe WikiPages::UpdateService, services: true do message: 'New wiki message' } end - let(:service) { described_class.new(project, user, opts) } + + subject(:service) { described_class.new(project, user, opts) } + + before do + project.add_developer(user) + end describe '#execute' do - context "valid params" do - before do - allow(service).to receive(:execute_hooks) - project.add_master(user) - end - - subject { service.execute(wiki_page) } - - it 'updates the wiki page' do - is_expected.to be_valid - expect(subject.content).to eq(opts[:content]) - expect(subject.format).to eq(opts[:format].to_sym) - expect(subject.message).to eq(opts[:message]) - end - - it 'executes webhooks' do - expect(service).to have_received(:execute_hooks).once.with(subject, 'update') - end + it 'updates the wiki page' do + updated_page = service.execute(page) + + expect(updated_page).to be_valid + expect(updated_page.message).to eq(opts[:message]) + expect(updated_page.content).to eq(opts[:content]) + expect(updated_page.format).to eq(opts[:format].to_sym) + end + + it 'executes webhooks' do + expect(service).to receive(:execute_hooks).once + .with(instance_of(WikiPage), 'update') + + service.execute(page) end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5cbb3dafcdf..d11e0cad066 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -26,6 +26,9 @@ if ENV['CI'] && !ENV['NO_KNAPSACK'] Knapsack::Adapters::RSpecAdapter.bind end +# require rainbow gem String monkeypatch, so we can test SystemChecks +require 'rainbow/ext/string' + # Requires supporting ruby files with custom matchers and macros, etc, # in spec/support/ and its subdirectories. Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f } @@ -104,6 +107,14 @@ RSpec.configure do |config| ActiveRecord::Migrator.migrate(migrations_paths) end 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/controllers/githubish_import_controller_shared_examples.rb b/spec/support/controllers/githubish_import_controller_shared_examples.rb index c59b30c772d..d6b40db09ce 100644 --- a/spec/support/controllers/githubish_import_controller_shared_examples.rb +++ b/spec/support/controllers/githubish_import_controller_shared_examples.rb @@ -209,9 +209,13 @@ shared_examples 'a GitHub-ish import controller: POST create' do end context 'user has chosen a namespace and name for the project' do - let(:test_namespace) { create(:namespace, name: 'test_namespace', owner: user) } + let(:test_namespace) { create(:group, name: 'test_namespace') } let(:test_name) { 'test_name' } + before do + test_namespace.add_owner(user) + end + it 'takes the selected namespace and name' do expect(Gitlab::GithubImport::ProjectCreator). to receive(:new).with(provider_repo, test_name, test_namespace, user, access_params, type: provider). @@ -230,10 +234,14 @@ shared_examples 'a GitHub-ish import controller: POST create' do end context 'user has chosen an existing nested namespace and name for the project' do - let(:parent_namespace) { create(:namespace, name: 'foo', owner: user) } - let(:nested_namespace) { create(:namespace, name: 'bar', parent: parent_namespace, owner: user) } + let(:parent_namespace) { create(:group, name: 'foo', owner: user) } + let(:nested_namespace) { create(:group, name: 'bar', parent: parent_namespace) } let(:test_name) { 'test_name' } + before do + nested_namespace.add_owner(user) + end + it 'takes the selected namespace and name' do expect(Gitlab::GithubImport::ProjectCreator). to receive(:new).with(provider_repo, test_name, nested_namespace, user, access_params, type: provider). @@ -276,7 +284,7 @@ shared_examples 'a GitHub-ish import controller: POST create' do context 'user has chosen existent and non-existent nested namespaces and name for the project' do let(:test_name) { 'test_name' } - let!(:parent_namespace) { create(:namespace, name: 'foo', owner: user) } + let!(:parent_namespace) { create(:group, name: 'foo', owner: user) } it 'takes the selected namespace and name' do expect(Gitlab::GithubImport::ProjectCreator). diff --git a/spec/support/cycle_analytics_helpers.rb b/spec/support/cycle_analytics_helpers.rb index 66545127a44..6e1eb5c678d 100644 --- a/spec/support/cycle_analytics_helpers.rb +++ b/spec/support/cycle_analytics_helpers.rb @@ -51,12 +51,43 @@ module CycleAnalyticsHelpers end def deploy_master(environment: 'production') - CreateDeploymentService.new(project, user, { - environment: environment, - ref: 'master', - tag: false, - sha: project.repository.commit('master').sha - }).execute + dummy_job = + case environment + when 'production' + dummy_production_job + when 'staging' + dummy_staging_job + else + raise ArgumentError + end + + CreateDeploymentService.new(dummy_job).execute + end + + def dummy_production_job + @dummy_job ||= new_dummy_job('production') + end + + def dummy_staging_job + @dummy_job ||= new_dummy_job('staging') + end + + def dummy_pipeline + @dummy_pipeline ||= + Ci::Pipeline.new(sha: project.repository.commit('master').sha) + end + + def new_dummy_job(environment) + project.environments.find_or_create_by(name: environment) + + Ci::Build.new( + project: project, + user: user, + environment: environment, + ref: 'master', + tag: false, + name: 'dummy', + pipeline: dummy_pipeline) end end diff --git a/spec/support/gitaly.rb b/spec/support/gitaly.rb index 7aca902fc61..2bf159002a0 100644 --- a/spec/support/gitaly.rb +++ b/spec/support/gitaly.rb @@ -1,6 +1,7 @@ if Gitlab::GitalyClient.enabled? RSpec.configure do |config| - config.before(:each) do + config.before(:each) do |example| + next if example.metadata[:skip_gitaly_mock] allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(true) end end diff --git a/spec/support/helpers/key_generator_helper.rb b/spec/support/helpers/key_generator_helper.rb new file mode 100644 index 00000000000..b1c289ffef7 --- /dev/null +++ b/spec/support/helpers/key_generator_helper.rb @@ -0,0 +1,41 @@ +module Spec + module Support + module Helpers + class KeyGeneratorHelper + # The components in a openssh .pub / known_host RSA public key. + RSA_COMPONENTS = ['ssh-rsa', :e, :n].freeze + + attr_reader :size + + def initialize(size = 2048) + @size = size + end + + def generate + key = OpenSSL::PKey::RSA.generate(size) + components = RSA_COMPONENTS.map do |component| + key.respond_to?(component) ? encode_mpi(key.public_send(component)) : component + end + + # Ruby tries to be helpful and adds new lines every 60 bytes :( + 'ssh-rsa ' + [pack_pubkey_components(components)].pack('m').delete("\n") + end + + private + + # Encodes an openssh-mpi-encoded integer. + def encode_mpi(n) + chars, n = [], n.to_i + chars << (n & 0xff) && n >>= 8 while n != 0 + chars << 0 if chars.empty? || chars.last >= 0x80 + chars.reverse.pack('C*') + end + + # Packs string components into an openssh-encoded pubkey. + def pack_pubkey_components(strings) + (strings.map { |s| [s.length].pack('N') }).zip(strings).flatten.join + end + end + end + end +end diff --git a/spec/support/import_spec_helper.rb b/spec/support/import_spec_helper.rb index 6710962f082..d4eced724fa 100644 --- a/spec/support/import_spec_helper.rb +++ b/spec/support/import_spec_helper.rb @@ -28,6 +28,6 @@ module ImportSpecHelper app_id: 'asd123', app_secret: 'asd123' ) - allow(Gitlab.config.omniauth).to receive(:providers).and_return([provider]) + stub_omniauth_setting(providers: [provider]) end end diff --git a/spec/support/issuable_shared_examples.rb b/spec/support/issuable_shared_examples.rb new file mode 100644 index 00000000000..03011535351 --- /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/support/kubernetes_helpers.rb b/spec/support/kubernetes_helpers.rb index d2a1ded57ff..9280fad4ace 100644 --- a/spec/support/kubernetes_helpers.rb +++ b/spec/support/kubernetes_helpers.rb @@ -41,7 +41,7 @@ module KubernetesHelpers containers.map do |container| terminal = { selectors: { pod: pod_name, container: container['name'] }, - url: container_exec_url(service.api_url, service.namespace, pod_name, container['name']), + url: container_exec_url(service.api_url, service.actual_namespace, pod_name, container['name']), subprotocols: ['channel.k8s.io'], headers: { 'Authorization' => ["Bearer #{service.token}"] }, created_at: DateTime.parse(pod['metadata']['creationTimestamp']), diff --git a/spec/support/matchers/execute_check.rb b/spec/support/matchers/execute_check.rb new file mode 100644 index 00000000000..7232fad52fb --- /dev/null +++ b/spec/support/matchers/execute_check.rb @@ -0,0 +1,23 @@ +RSpec::Matchers.define :execute_check do |expected| + match do |actual| + expect(actual).to eq(SystemCheck) + expect(actual).to receive(:run) do |*args| + expect(args[1]).to include(expected) + end + end + + match_when_negated do |actual| + expect(actual).to eq(SystemCheck) + expect(actual).to receive(:run) do |*args| + expect(args[1]).not_to include(expected) + end + end + + failure_message do |actual| + 'This matcher must be used with SystemCheck' unless actual == SystemCheck + end + + failure_message_when_negated do |actual| + 'This matcher must be used with SystemCheck' unless actual == SystemCheck + end +end diff --git a/spec/support/rake_helpers.rb b/spec/support/rake_helpers.rb index 4a8158ed79b..5cb415111d2 100644 --- a/spec/support/rake_helpers.rb +++ b/spec/support/rake_helpers.rb @@ -7,4 +7,9 @@ module RakeHelpers def stub_warn_user_is_not_gitlab allow_any_instance_of(Object).to receive(:warn_user_is_not_gitlab) end + + def silence_output + allow($stdout).to receive(:puts) + allow($stdout).to receive(:print) + end end diff --git a/spec/support/stub_configuration.rb b/spec/support/stub_configuration.rb index 444adcc1906..b39a23bd18a 100644 --- a/spec/support/stub_configuration.rb +++ b/spec/support/stub_configuration.rb @@ -25,6 +25,10 @@ module StubConfiguration allow(Gitlab.config.mattermost).to receive_messages(messages) end + def stub_omniauth_setting(messages) + allow(Gitlab.config.omniauth).to receive_messages(messages) + end + private # Modifies stubbed messages to also stub possible predicate versions diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index b168098edea..72b3b226c1e 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -40,7 +40,7 @@ module TestEnv 'wip' => 'b9238ee', 'csv' => '3dd0896', 'v1.1.0' => 'b83d6e3', - 'add-ipython-files' => '6d85bb6', + 'add-ipython-files' => '93ee732', 'add-pdf-file' => 'e774ebd' }.freeze @@ -123,7 +123,7 @@ module TestEnv socket_path = Gitlab::GitalyClient.address('default').sub(/\Aunix:/, '') gitaly_dir = File.dirname(socket_path) - unless File.directory?(gitaly_dir) || system('rake', "gitlab:gitaly:install[#{gitaly_dir}]") + unless !gitaly_needs_update?(gitaly_dir) || system('rake', "gitlab:gitaly:install[#{gitaly_dir}]") raise "Can't clone gitaly" end @@ -252,4 +252,15 @@ module TestEnv cleanup && init unless reset.call end end + + def gitaly_needs_update?(gitaly_dir) + gitaly_version = File.read(File.join(gitaly_dir, 'VERSION')).strip + + # Notice that this will always yield true when using branch versions + # (`=branch_name`), but that actually makes sure the server is always based + # on the latest branch revision. + gitaly_version != Gitlab::GitalyClient.expected_server_version + rescue Errno::ENOENT + true + end end diff --git a/spec/uploaders/artifact_uploader_spec.rb b/spec/uploaders/artifact_uploader_spec.rb new file mode 100644 index 00000000000..24e2e3a9f0e --- /dev/null +++ b/spec/uploaders/artifact_uploader_spec.rb @@ -0,0 +1,38 @@ +require 'rails_helper' + +describe ArtifactUploader do + let(:job) { create(:ci_build) } + let(:uploader) { described_class.new(job, :artifacts_file) } + let(:path) { Gitlab.config.artifacts.path } + + describe '.local_artifacts_store' do + subject { described_class.local_artifacts_store } + + it "delegate to artifacts path" do + expect(Gitlab.config.artifacts).to receive(:path) + + subject + end + end + + describe '.artifacts_upload_path' do + subject { described_class.artifacts_upload_path } + + it { is_expected.to start_with(path) } + it { is_expected.to end_with('tmp/uploads/') } + end + + describe '#store_dir' do + subject { uploader.store_dir } + + it { is_expected.to start_with(path) } + it { is_expected.to end_with("#{job.project_id}/#{job.id}") } + end + + describe '#cache_dir' do + subject { uploader.cache_dir } + + it { is_expected.to start_with(path) } + it { is_expected.to end_with('tmp/cache') } + end +end diff --git a/spec/uploaders/gitlab_uploader_spec.rb b/spec/uploaders/gitlab_uploader_spec.rb new file mode 100644 index 00000000000..78e9d9cf46c --- /dev/null +++ b/spec/uploaders/gitlab_uploader_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' +require 'carrierwave/storage/fog' + +describe GitlabUploader do + let(:uploader_class) { Class.new(described_class) } + + subject { uploader_class.new } + + describe '#file_storage?' do + context 'when file storage is used' do + before do + uploader_class.storage(:file) + end + + it { is_expected.to be_file_storage } + end + + context 'when is remote storage' do + before do + uploader_class.storage(:fog) + end + + it { is_expected.not_to be_file_storage } + end + end + + describe '#file_cache_storage?' do + context 'when file storage is used' do + before do + uploader_class.cache_storage(:file) + end + + it { is_expected.to be_file_cache_storage } + end + + context 'when is remote storage' do + before do + uploader_class.cache_storage(:fog) + end + + it { is_expected.not_to be_file_cache_storage } + end + end + + describe '#move_to_cache' do + it 'is true' do + expect(subject.move_to_cache).to eq(true) + end + end + + describe '#move_to_store' do + it 'is true' do + expect(subject.move_to_store).to eq(true) + end + end +end diff --git a/spec/uploaders/lfs_object_uploader_spec.rb b/spec/uploaders/lfs_object_uploader_spec.rb new file mode 100644 index 00000000000..c3b72e7d677 --- /dev/null +++ b/spec/uploaders/lfs_object_uploader_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe LfsObjectUploader do + let(:uploader) { described_class.new(build_stubbed(:empty_project)) } + + describe '#cache!' do + it 'caches the file in the cache directory' do + # One to get the work dir, the other to remove it + expect(uploader).to receive(:workfile_path).exactly(2).times.and_call_original + expect(FileUtils).to receive(:mv).with(anything, /^#{uploader.work_dir}/).and_call_original + expect(FileUtils).to receive(:mv).with(/^#{uploader.work_dir}/, /^#{uploader.cache_dir}/).and_call_original + + fixture = Rails.root.join('spec', 'fixtures', 'rails_sample.jpg') + uploader.cache!(fixture_file_upload(fixture)) + + expect(uploader.file.path).to start_with(uploader.cache_dir) + end + end + + describe '#move_to_cache' do + it 'is true' do + expect(uploader.move_to_cache).to eq(true) + end + end + + describe '#move_to_store' do + it 'is true' do + expect(uploader.move_to_store).to eq(true) + end + end +end diff --git a/spec/validators/dynamic_path_validator_spec.rb b/spec/validators/dynamic_path_validator_spec.rb index 03e23781d1b..8dbf3eecd23 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 @@ -15,31 +37,31 @@ describe DynamicPathValidator do end context 'for group' do - it 'calls valid_namespace_path?' do + it 'calls valid_group_path?' do group = build(:group, :nested, path: 'activity') - expect(described_class).to receive(:valid_namespace_path?).with(group.full_path).and_call_original + expect(described_class).to receive(:valid_group_path?).with(group.full_path).and_call_original expect(validator.path_valid_for_record?(group, 'activity')).to be_falsey end end context 'for user' do - it 'calls valid_namespace_path?' do + it 'calls valid_user_path?' do user = build(:user, username: 'activity') - expect(described_class).to receive(:valid_namespace_path?).with(user.full_path).and_call_original + expect(described_class).to receive(:valid_user_path?).with(user.full_path).and_call_original expect(validator.path_valid_for_record?(user, 'activity')).to be_truthy end end context 'for user namespace' do - it 'calls valid_namespace_path?' do + it 'calls valid_user_path?' do user = create(:user, username: 'activity') namespace = user.namespace - expect(described_class).to receive(:valid_namespace_path?).with(namespace.full_path).and_call_original + expect(described_class).to receive(:valid_user_path?).with(namespace.full_path).and_call_original expect(validator.path_valid_for_record?(namespace, 'activity')).to be_truthy end @@ -52,7 +74,7 @@ describe DynamicPathValidator do validator.validate_each(group, :path, "Path with spaces, and comma's!") - expect(group.errors[:path]).to include(Gitlab::Regex.namespace_regex_message) + expect(group.errors[:path]).to include(Gitlab::PathRegex.namespace_format_message) end it 'adds a message when the path is not in the correct format' do diff --git a/spec/views/ci/status/_badge.html.haml_spec.rb b/spec/views/ci/status/_badge.html.haml_spec.rb index c62450fb8e2..72323da2838 100644 --- a/spec/views/ci/status/_badge.html.haml_spec.rb +++ b/spec/views/ci/status/_badge.html.haml_spec.rb @@ -16,7 +16,7 @@ describe 'ci/status/_badge', :view do end it 'has link to build details page' do - details_path = namespace_project_build_path( + details_path = namespace_project_job_path( project.namespace, project, build) render_status(build) diff --git a/spec/views/projects/blob/_viewer.html.haml_spec.rb b/spec/views/projects/blob/_viewer.html.haml_spec.rb index c6b0ed8da3c..bbd7f98fa8d 100644 --- a/spec/views/projects/blob/_viewer.html.haml_spec.rb +++ b/spec/views/projects/blob/_viewer.html.haml_spec.rb @@ -10,8 +10,8 @@ describe 'projects/blob/_viewer.html.haml', :view do include BlobViewer::Rich self.partial_name = 'text' - self.overridable_max_size = 1.megabyte - self.max_size = 5.megabytes + self.collapse_limit = 1.megabyte + self.size_limit = 5.megabytes self.load_async = true end end diff --git a/spec/views/projects/builds/_build.html.haml_spec.rb b/spec/views/projects/jobs/_build.html.haml_spec.rb index 751482cac42..1d58891036e 100644 --- a/spec/views/projects/builds/_build.html.haml_spec.rb +++ b/spec/views/projects/jobs/_build.html.haml_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'projects/ci/builds/_build' do +describe 'projects/ci/jobs/_build' do include Devise::Test::ControllerHelpers let(:project) { create(:project, :repository) } diff --git a/spec/views/projects/builds/_generic_commit_status.html.haml_spec.rb b/spec/views/projects/jobs/_generic_commit_status.html.haml_spec.rb index dc2ffc9dc47..dc2ffc9dc47 100644 --- a/spec/views/projects/builds/_generic_commit_status.html.haml_spec.rb +++ b/spec/views/projects/jobs/_generic_commit_status.html.haml_spec.rb diff --git a/spec/views/projects/builds/show.html.haml_spec.rb b/spec/views/projects/jobs/show.html.haml_spec.rb index 0f39df0f250..8f2822f5dc5 100644 --- a/spec/views/projects/builds/show.html.haml_spec.rb +++ b/spec/views/projects/jobs/show.html.haml_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'projects/builds/show', :view do +describe 'projects/jobs/show', :view do let(:project) { create(:project, :repository) } let(:build) { create(:ci_build, pipeline: pipeline) } @@ -278,7 +278,7 @@ describe 'projects/builds/show', :view do it 'links to issues/new with the title and description filled in' do title = "Build Failed ##{build.id}" - build_url = namespace_project_build_url(project.namespace, project, build) + build_url = namespace_project_job_url(project.namespace, project, build) href = new_namespace_project_issue_path( project.namespace, project, diff --git a/spec/workers/gitlab_usage_ping_worker_spec.rb b/spec/workers/gitlab_usage_ping_worker_spec.rb index 26241044533..49b4e04dc7c 100644 --- a/spec/workers/gitlab_usage_ping_worker_spec.rb +++ b/spec/workers/gitlab_usage_ping_worker_spec.rb @@ -3,21 +3,11 @@ require 'spec_helper' describe GitlabUsagePingWorker do subject { described_class.new } - it "sends POST request" do - stub_application_setting(usage_ping_enabled: true) + it 'delegates to SubmitUsagePingService' do + allow(subject).to receive(:try_obtain_lease).and_return(true) - stub_request(:post, "https://version.gitlab.com/usage_data"). - to_return(status: 200, body: '', headers: {}) - expect(Gitlab::UsageData).to receive(:to_json).with({ force_refresh: true }).and_call_original - expect(subject).to receive(:try_obtain_lease).and_return(true) + expect_any_instance_of(SubmitUsagePingService).to receive(:execute) - expect(subject.perform.response.code.to_i).to eq(200) - end - - it "does not run if usage ping is disabled" do - stub_application_setting(usage_ping_enabled: false) - - expect(subject).not_to receive(:try_obtain_lease) - expect(subject).not_to receive(:perform) + subject.perform end end diff --git a/spec/workers/pipeline_schedule_worker_spec.rb b/spec/workers/pipeline_schedule_worker_spec.rb index 9c650354d72..14ed8b7811e 100644 --- a/spec/workers/pipeline_schedule_worker_spec.rb +++ b/spec/workers/pipeline_schedule_worker_spec.rb @@ -23,7 +23,8 @@ describe PipelineScheduleWorker do context 'when there is a scheduled pipeline within next_run_at' do it 'creates a new pipeline' do - expect { subject }.to change { project.pipelines.count }.by(1) + expect{ subject }.to change { project.pipelines.count }.by(1) + expect(Ci::Pipeline.last).to be_schedule end it 'updates the next_run_at field' do diff --git a/spec/workers/process_commit_worker_spec.rb b/spec/workers/process_commit_worker_spec.rb index 6295856b461..4e036285e8c 100644 --- a/spec/workers/process_commit_worker_spec.rb +++ b/spec/workers/process_commit_worker_spec.rb @@ -20,14 +20,6 @@ describe ProcessCommitWorker do worker.perform(project.id, -1, commit.to_hash) end - it 'does not process the commit when no issues are referenced' do - allow(worker).to receive(:build_commit).and_return(double(matches_cross_reference_regex?: false)) - - expect(worker).not_to receive(:process_commit_message) - - worker.perform(project.id, user.id, commit.to_hash) - end - it 'processes the commit message' do expect(worker).to receive(:process_commit_message).and_call_original @@ -39,6 +31,18 @@ describe ProcessCommitWorker do worker.perform(project.id, user.id, commit.to_hash) end + + context 'when commit already exists in upstream project' do + let(:forked) { create(:project, :public) } + + it 'does not process commit message' do + create(:forked_project_link, forked_to_project: forked, forked_from_project: project) + + expect(worker).not_to receive(:process_commit_message) + + worker.perform(forked.id, user.id, forked.commit.to_hash) + end + end end describe '#process_commit_message' do diff --git a/spec/workers/remove_old_web_hook_logs_worker_spec.rb b/spec/workers/remove_old_web_hook_logs_worker_spec.rb new file mode 100644 index 00000000000..6d26ba5dfa0 --- /dev/null +++ b/spec/workers/remove_old_web_hook_logs_worker_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +describe RemoveOldWebHookLogsWorker do + subject { described_class.new } + + describe '#perform' do + let!(:week_old_record) { create(:web_hook_log, created_at: Time.now - 1.week) } + let!(:three_days_old_record) { create(:web_hook_log, created_at: Time.now - 3.days) } + let!(:one_day_old_record) { create(:web_hook_log, created_at: Time.now - 1.day) } + + it 'removes web hook logs older than 2 days' do + subject.perform + + expect(WebHookLog.all).to include(one_day_old_record) + expect(WebHookLog.all).not_to include(week_old_record, three_days_old_record) + end + end +end diff --git a/vendor/assets/javascripts/task_list.js b/vendor/assets/javascripts/task_list.js deleted file mode 100644 index 9fbfef03f6d..00000000000 --- a/vendor/assets/javascripts/task_list.js +++ /dev/null @@ -1,258 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2014 GitHub, Inc. -// -// 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. -// TaskList Behavior -// -/*= provides tasklist:enabled */ -/*= provides tasklist:disabled */ -/*= provides tasklist:change */ -/*= provides tasklist:changed */ -// -// -// Enables Task List update behavior. -// -// ### Example Markup -// -// <div class="js-task-list-container"> -// <ul class="task-list"> -// <li class="task-list-item"> -// <input type="checkbox" class="js-task-list-item-checkbox" disabled /> -// text -// </li> -// </ul> -// <form> -// <textarea class="js-task-list-field">- [ ] text</textarea> -// </form> -// </div> -// -// ### Specification -// -// TaskLists MUST be contained in a `(div).js-task-list-container`. -// -// TaskList Items SHOULD be an a list (`UL`/`OL`) element. -// -// Task list items MUST match `(input).task-list-item-checkbox` and MUST be -// `disabled` by default. -// -// TaskLists MUST have a `(textarea).js-task-list-field` form element whose -// `value` attribute is the source (Markdown) to be udpated. The source MUST -// follow the syntax guidelines. -// -// TaskList updates trigger `tasklist:change` events. If the change is -// successful, `tasklist:changed` is fired. The change can be canceled. -// -// jQuery is required. -// -// ### Methods -// -// `.taskList('enable')` or `.taskList()` -// -// Enables TaskList updates for the container. -// -// `.taskList('disable')` -// -// Disables TaskList updates for the container. -// -//# ### Events -// -// `tasklist:enabled` -// -// Fired when the TaskList is enabled. -// -// * **Synchronicity** Sync -// * **Bubbles** Yes -// * **Cancelable** No -// * **Target** `.js-task-list-container` -// -// `tasklist:disabled` -// -// Fired when the TaskList is disabled. -// -// * **Synchronicity** Sync -// * **Bubbles** Yes -// * **Cancelable** No -// * **Target** `.js-task-list-container` -// -// `tasklist:change` -// -// Fired before the TaskList item change takes affect. -// -// * **Synchronicity** Sync -// * **Bubbles** Yes -// * **Cancelable** Yes -// * **Target** `.js-task-list-field` -// -// `tasklist:changed` -// -// Fired once the TaskList item change has taken affect. -// -// * **Synchronicity** Sync -// * **Bubbles** Yes -// * **Cancelable** No -// * **Target** `.js-task-list-field` -// -// ### NOTE -// -// Task list checkboxes are rendered as disabled by default because rendered -// user content is cached without regard for the viewer. -(function() { - var codeFencesPattern, complete, completePattern, disableTaskList, disableTaskLists, enableTaskList, enableTaskLists, escapePattern, incomplete, incompletePattern, itemPattern, itemsInParasPattern, updateTaskList, updateTaskListItem, - indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; - - incomplete = "[ ]"; - - complete = "[x]"; - - // Escapes the String for regular expression matching. - escapePattern = function(str) { - return str.replace(/([\[\]])/g, "\\$1").replace(/\s/, "\\s").replace("x", "[xX]"); - }; - - incompletePattern = RegExp("" + (escapePattern(incomplete))); // escape square brackets - // match all white space - completePattern = RegExp("" + (escapePattern(complete))); // match all cases - - // Pattern used to identify all task list items. - // Useful when you need iterate over all items. - itemPattern = RegExp("^(?:\\s*(?:>\\s*)*(?:[-+*]|(?:\\d+\\.)))\\s*(" + (escapePattern(complete)) + "|" + (escapePattern(incomplete)) + ")\\s+(?!\\(.*?\\))(?=(?:\\[.*?\\]\\s*(?:\\[.*?\\]|\\(.*?\\))\\s*)*(?:[^\\[]|$))"); - - // prefix, consisting of - // optional leading whitespace - // zero or more blockquotes - // list item indicator - // optional whitespace prefix - // checkbox - // is followed by whitespace - // is not part of a [foo](url) link - // and is followed by zero or more links - // and either a non-link or the end of the string - // Used to filter out code fences from the source for comparison only. - // http://rubular.com/r/x5EwZVrloI - // Modified slightly due to issues with JS - codeFencesPattern = /^`{3}(?:\s*\w+)?[\S\s].*[\S\s]^`{3}$/mg; - - // ``` - // followed by optional language - // whitespace - // code - // whitespace - // ``` - // Used to filter out potential mismatches (items not in lists). - // http://rubular.com/r/OInl6CiePy - itemsInParasPattern = RegExp("^(" + (escapePattern(complete)) + "|" + (escapePattern(incomplete)) + ").+$", "g"); - - // Given the source text, updates the appropriate task list item to match the - // given checked value. - // - // Returns the updated String text. - updateTaskListItem = function(source, itemIndex, checked) { - var clean, index, line, result; - clean = source.replace(/\r/g, '').replace(codeFencesPattern, '').replace(itemsInParasPattern, '').split("\n"); - index = 0; - result = (function() { - var i, len, ref, results; - ref = source.split("\n"); - results = []; - for (i = 0, len = ref.length; i < len; i++) { - line = ref[i]; - if (indexOf.call(clean, line) >= 0 && line.match(itemPattern)) { - index += 1; - if (index === itemIndex) { - line = checked ? line.replace(incompletePattern, complete) : line.replace(completePattern, incomplete); - } - } - results.push(line); - } - return results; - })(); - return result.join("\n"); - }; - - // Updates the $field value to reflect the state of $item. - // Triggers the `tasklist:change` event before the value has changed, and fires - // a `tasklist:changed` event once the value has changed. - updateTaskList = function($item) { - var $container, $field, checked, event, index; - $container = $item.closest('.js-task-list-container'); - $field = $container.find('.js-task-list-field'); - index = 1 + $container.find('.task-list-item-checkbox').index($item); - checked = $item.prop('checked'); - event = $.Event('tasklist:change'); - $field.trigger(event, [index, checked]); - if (!event.isDefaultPrevented()) { - $field.val(updateTaskListItem($field.val(), index, checked)); - $field.trigger('change'); - return $field.trigger('tasklist:changed', [index, checked]); - } - }; - - // When the task list item checkbox is updated, submit the change - $(document).on('change', '.task-list-item-checkbox', function() { - return updateTaskList($(this)); - }); - - // Enables TaskList item changes. - enableTaskList = function($container) { - if ($container.find('.js-task-list-field').length > 0) { - $container.find('.task-list-item').addClass('enabled').find('.task-list-item-checkbox').attr('disabled', null); - return $container.addClass('is-task-list-enabled').trigger('tasklist:enabled'); - } - }; - - // Enables a collection of TaskList containers. - enableTaskLists = function($containers) { - var container, i, len, results; - results = []; - for (i = 0, len = $containers.length; i < len; i++) { - container = $containers[i]; - results.push(enableTaskList($(container))); - } - return results; - }; - - // Disable TaskList item changes. - disableTaskList = function($container) { - $container.find('.task-list-item').removeClass('enabled').find('.task-list-item-checkbox').attr('disabled', 'disabled'); - return $container.removeClass('is-task-list-enabled').trigger('tasklist:disabled'); - }; - - // Disables a collection of TaskList containers. - disableTaskLists = function($containers) { - var container, i, len, results; - results = []; - for (i = 0, len = $containers.length; i < len; i++) { - container = $containers[i]; - results.push(disableTaskList($(container))); - } - return results; - }; - - $.fn.taskList = function(method) { - var $container, methods; - $container = $(this).closest('.js-task-list-container'); - methods = { - enable: enableTaskLists, - disable: disableTaskLists - }; - return methods[method || 'enable']($container); - }; - -}).call(this); diff --git a/yarn.lock b/yarn.lock index 8aac2b1b1cd..1db64aead8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25,7 +25,7 @@ acorn-jsx@^3.0.0: dependencies: acorn "^3.0.4" -acorn@4.0.4: +acorn@4.0.4, acorn@^4.0.3: version "4.0.4" resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.4.tgz#17a8d6a7a6c4ef538b814ec9abac2779293bf30a" @@ -33,9 +33,9 @@ acorn@^3.0.4: version "3.3.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" -acorn@^4.0.11, acorn@^4.0.3, acorn@^4.0.4: - version "4.0.11" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.11.tgz#edcda3bd937e7556410d42ed5860f67399c794c0" +acorn@^5.0.0, acorn@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.0.3.tgz#c460df08491463f028ccb82eab3730bf01087b3d" after@0.8.2: version "0.8.2" @@ -1560,10 +1560,20 @@ debug@2.6.0, debug@^2.1.0, debug@^2.1.1, debug@^2.2.0: dependencies: ms "0.7.2" +debug@2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.7.tgz#92bad1f6d05bbb6bba22cca88bcd0ec894c2861e" + dependencies: + ms "2.0.0" + decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" +deckar01-task_list@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/deckar01-task_list/-/deckar01-task_list-2.0.0.tgz#7f7a595430d21b3036ed5dfbf97d6b65de18e2c9" + deep-extend@~0.4.0: version "0.4.1" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.1.tgz#efe4113d08085f4e6f9687759810f807469e2253" @@ -1612,7 +1622,7 @@ delegates@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" -depd@~1.1.0: +depd@1.1.0, depd@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.0.tgz#e1bd82c6aab6ced965b97b88b17ed3e528ca18c3" @@ -1733,7 +1743,7 @@ ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" -ejs@^2.5.5: +ejs@^2.5.6: version "2.5.6" resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.6.tgz#479636bfa3fe3b1debd52087f0acb204b4f19c88" @@ -2081,9 +2091,9 @@ esutils@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" -etag@~1.7.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.7.0.tgz#03d30b5f67dd6e632d2945d30d6652731a34d5d8" +etag@~1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.0.tgz#6f631aef336d6c46362b51764044ce216be3c051" eve-raphael@0.5.0: version "0.5.0" @@ -2166,9 +2176,9 @@ exports-loader@^0.6.4: loader-utils "^1.0.2" source-map "0.5.x" -express@^4.13.3, express@^4.14.1: - version "4.14.1" - resolved "https://registry.yarnpkg.com/express/-/express-4.14.1.tgz#646c237f766f148c2120aff073817b9e4d7e0d33" +express@^4.13.3, express@^4.15.2: + version "4.15.3" + resolved "https://registry.yarnpkg.com/express/-/express-4.15.3.tgz#bab65d0f03aa80c358408972fc700f916944b662" dependencies: accepts "~1.3.3" array-flatten "1.1.1" @@ -2176,26 +2186,28 @@ express@^4.13.3, express@^4.14.1: content-type "~1.0.2" cookie "0.3.1" cookie-signature "1.0.6" - debug "~2.2.0" + debug "2.6.7" depd "~1.1.0" encodeurl "~1.0.1" escape-html "~1.0.3" - etag "~1.7.0" - finalhandler "0.5.1" - fresh "0.3.0" + etag "~1.8.0" + finalhandler "~1.0.3" + fresh "0.5.0" merge-descriptors "1.0.1" methods "~1.1.2" on-finished "~2.3.0" parseurl "~1.3.1" path-to-regexp "0.1.7" - proxy-addr "~1.1.3" - qs "6.2.0" + proxy-addr "~1.1.4" + qs "6.4.0" range-parser "~1.2.0" - send "0.14.2" - serve-static "~1.11.2" - type-is "~1.6.14" + send "0.15.3" + serve-static "1.12.3" + setprototypeof "1.0.3" + statuses "~1.3.1" + type-is "~1.6.15" utils-merge "1.0.0" - vary "~1.1.0" + vary "~1.1.1" extend@^3.0.0, extend@~3.0.0: version "3.0.0" @@ -2287,9 +2299,9 @@ filesize@3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.3.0.tgz#53149ea3460e3b2e024962a51648aa572cf98122" -filesize@^3.5.4: - version "3.5.4" - resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.5.4.tgz#742fc7fb6aef4ee3878682600c22f840731e1fda" +filesize@^3.5.9: + version "3.5.10" + resolved "https://registry.yarnpkg.com/filesize/-/filesize-3.5.10.tgz#fc8fa23ddb4ef9e5e0ab6e1e64f679a24a56761f" fill-range@^2.1.0: version "2.2.3" @@ -2311,13 +2323,15 @@ finalhandler@0.5.0: statuses "~1.3.0" unpipe "~1.0.0" -finalhandler@0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-0.5.1.tgz#2c400d8d4530935bc232549c5fa385ec07de6fcd" +finalhandler@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.0.3.tgz#ef47e77950e999780e86022a560e3217e0d0cc89" dependencies: - debug "~2.2.0" + debug "2.6.7" + encodeurl "~1.0.1" escape-html "~1.0.3" on-finished "~2.3.0" + parseurl "~1.3.1" statuses "~1.3.1" unpipe "~1.0.0" @@ -2385,9 +2399,9 @@ forwarded@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.0.tgz#19ef9874c4ae1c297bcf078fde63a09b66a84363" -fresh@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.3.0.tgz#651f838e22424e7566de161d8358caa199f83d4f" +fresh@0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.0.tgz#f474ca5e6a9246d6fd8e0953cfa9b9c805afa78e" from@~0: version "0.1.7" @@ -2689,6 +2703,15 @@ http-errors@~1.5.0, http-errors@~1.5.1: setprototypeof "1.0.2" statuses ">= 1.3.1 < 2" +http-errors@~1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.1.tgz#5f8b8ed98aca545656bf572997387f904a722257" + dependencies: + depd "1.1.0" + inherits "2.0.3" + setprototypeof "1.0.3" + statuses ">= 1.3.1 < 2" + http-proxy-middleware@~0.17.4: version "0.17.4" resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.4.tgz#642e8848851d66f09d4f124912846dbaeb41b833" @@ -2808,9 +2831,9 @@ invert-kv@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" -ipaddr.js@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.2.0.tgz#8aba49c9192799585bdd643e0ccb50e8ae777ba4" +ipaddr.js@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.3.0.tgz#1e03a52fdad83a8bbb2b25cbf4998b4cffcd3dec" is-absolute-url@^2.0.0: version "2.1.0" @@ -3198,7 +3221,7 @@ json3@3.3.2, json3@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1" -json5@^0.5.0: +json5@^0.5.0, json5@^0.5.1: version "0.5.1" resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821" @@ -3643,7 +3666,17 @@ miller-rabin@^4.0.0: version "1.26.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.26.0.tgz#eaffcd0e4fc6935cf8134da246e2e6c35305adff" -mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.13, mime-types@~2.1.7: +mime-db@~1.27.0: + version "1.27.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.27.0.tgz#820f572296bbd20ec25ed55e5b5de869e5436eb1" + +mime-types@^2.1.12, mime-types@~2.1.15, mime-types@~2.1.7: + version "2.1.15" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.15.tgz#a4ebf5064094569237b8cf70046776d09fc92aed" + dependencies: + mime-db "~1.27.0" + +mime-types@~2.1.11: version "2.1.14" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.14.tgz#f7ef7d97583fcaf3b7d282b6f8b5679dab1e94ee" dependencies: @@ -3699,10 +3732,18 @@ ms@0.7.2: version "0.7.2" resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.2.tgz#ae25cf2512b3885a1d95d7f037868d8431124765" +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + mute-stream@0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" +name-all-modules-plugin@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/name-all-modules-plugin/-/name-all-modules-plugin-1.0.1.tgz#0abfb6ad835718b9fb4def0674e06657a954375c" + nan@^2.0.0, nan@^2.3.0: version "2.5.1" resolved "https://registry.yarnpkg.com/nan/-/nan-2.5.1.tgz#d5b01691253326a97a2bbee9e61c55d8d60351e2" @@ -3931,7 +3972,7 @@ onetime@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" -opener@^1.4.2: +opener@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/opener/-/opener-1.4.3.tgz#5c6da2c5d7e5831e8ffa3964950f8d6674ac90b8" @@ -4485,12 +4526,12 @@ proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" -proxy-addr@~1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.3.tgz#dc97502f5722e888467b3fa2297a7b1ff47df074" +proxy-addr@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-1.1.4.tgz#27e545f6960a44a627d9b44467e35c1b6b4ce2f3" dependencies: forwarded "~0.1.0" - ipaddr.js "1.2.0" + ipaddr.js "1.3.0" prr@~0.0.0: version "0.0.0" @@ -4532,14 +4573,14 @@ qjobs@^1.1.4: version "1.1.5" resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.1.5.tgz#659de9f2cf8dcc27a1481276f205377272382e73" -qs@6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.0.tgz#3b7848c03c2dece69a9522b0fae8c4126d745f3b" - qs@6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.1.tgz#ce03c5ff0935bc1d9d69a9f14cbd18e568d67625" +qs@6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" + qs@~6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.0.tgz#f403b264f23bc01228c74131b407f18d5ea5d442" @@ -4913,7 +4954,7 @@ rx-lite@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102" -safe-buffer@^5.0.1: +safe-buffer@^5.0.1, safe-buffer@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.0.1.tgz#d263ca54696cd8a306b5ca6551e92de57918fbe7" @@ -4947,20 +4988,20 @@ semver@~4.3.3: version "4.3.6" resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.6.tgz#300bc6e0e86374f7ba61068b5b1ecd57fc6532da" -send@0.14.2: - version "0.14.2" - resolved "https://registry.yarnpkg.com/send/-/send-0.14.2.tgz#39b0438b3f510be5dc6f667a11f71689368cdeef" +send@0.15.3: + version "0.15.3" + resolved "https://registry.yarnpkg.com/send/-/send-0.15.3.tgz#5013f9f99023df50d1bd9892c19e3defd1d53309" dependencies: - debug "~2.2.0" + debug "2.6.7" depd "~1.1.0" destroy "~1.0.4" encodeurl "~1.0.1" escape-html "~1.0.3" - etag "~1.7.0" - fresh "0.3.0" - http-errors "~1.5.1" + etag "~1.8.0" + fresh "0.5.0" + http-errors "~1.6.1" mime "1.3.4" - ms "0.7.2" + ms "2.0.0" on-finished "~2.3.0" range-parser "~1.2.0" statuses "~1.3.1" @@ -4977,14 +5018,14 @@ serve-index@^1.7.2: mime-types "~2.1.11" parseurl "~1.3.1" -serve-static@~1.11.2: - version "1.11.2" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.11.2.tgz#2cf9889bd4435a320cc36895c9aa57bd662e6ac7" +serve-static@1.12.3: + version "1.12.3" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.12.3.tgz#9f4ba19e2f3030c547f8af99107838ec38d5b1e2" dependencies: encodeurl "~1.0.1" escape-html "~1.0.3" parseurl "~1.3.1" - send "0.14.2" + send "0.15.3" set-blocking@^2.0.0, set-blocking@~2.0.0: version "2.0.0" @@ -5002,6 +5043,10 @@ setprototypeof@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.2.tgz#81a552141ec104b88e89ce383103ad5c66564d08" +setprototypeof@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04" + sha.js@^2.3.6: version "2.4.8" resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.8.tgz#37068c2c476b6baf402d14a49c67f597921f634f" @@ -5496,20 +5541,20 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" -type-is@~1.6.14: - version "1.6.14" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.14.tgz#e219639c17ded1ca0789092dd54a03826b817cb2" +type-is@~1.6.14, type-is@~1.6.15: + version "1.6.15" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.15.tgz#cab10fb4909e441c82842eafe1ad646c81804410" dependencies: media-typer "0.3.0" - mime-types "~2.1.13" + mime-types "~2.1.15" typedarray@^0.0.6, typedarray@~0.0.5: version "0.0.6" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" -uglify-js@^2.6, uglify-js@^2.8.5: - version "2.8.21" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.21.tgz#1733f669ae6f82fc90c7b25ec0f5c783ee375314" +uglify-js@^2.6, uglify-js@^2.8.27: + version "2.8.27" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.27.tgz#47787f912b0f242e5b984343be8e35e95f694c9c" dependencies: source-map "~0.5.1" yargs "~3.10.0" @@ -5528,6 +5573,10 @@ ultron@1.0.x: version "1.0.2" resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.0.2.tgz#ace116ab557cd197386a4e88f4685378c8b2e4fa" +ultron@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.0.tgz#b07a2e6a541a815fc6a34ccd4533baec307ca864" + unc-path-regex@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" @@ -5640,9 +5689,9 @@ validate-npm-package-license@^3.0.1: spdx-correct "~1.0.0" spdx-expression-parse "~1.0.0" -vary@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.0.tgz#e1e5affbbd16ae768dd2674394b9ad3022653140" +vary@~1.1.0, vary@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.1.tgz#67535ebb694c1d52257457984665323f587e8d37" vendors@^1.0.0: version "1.0.1" @@ -5729,20 +5778,21 @@ wbuf@^1.1.0, wbuf@^1.4.0: dependencies: minimalistic-assert "^1.0.0" -webpack-bundle-analyzer@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-2.3.0.tgz#0d05e96a43033f7cc57f6855b725782ba61e93a4" +webpack-bundle-analyzer@^2.8.2: + version "2.8.2" + resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-2.8.2.tgz#8b6240c29a9d63bc72f09d920fb050adbcce9fe8" dependencies: - acorn "^4.0.11" + acorn "^5.0.3" chalk "^1.1.3" commander "^2.9.0" - ejs "^2.5.5" - express "^4.14.1" - filesize "^3.5.4" + ejs "^2.5.6" + express "^4.15.2" + filesize "^3.5.9" gzip-size "^3.0.0" lodash "^4.17.4" mkdirp "^0.5.1" - opener "^1.4.2" + opener "^1.4.3" + ws "^2.3.1" webpack-dev-middleware@^1.0.11, webpack-dev-middleware@^1.9.0: version "1.10.0" @@ -5789,11 +5839,11 @@ webpack-sources@^0.2.3: source-list-map "^1.1.1" source-map "~0.5.3" -webpack@^2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-2.3.3.tgz#eecc083c18fb7bf958ea4f40b57a6640c5a0cc78" +webpack@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-2.6.1.tgz#2e0457f0abb1ac5df3ab106c69c672f236785f07" dependencies: - acorn "^4.0.4" + acorn "^5.0.0" acorn-dynamic-import "^2.0.0" ajv "^4.7.0" ajv-keywords "^1.1.1" @@ -5801,6 +5851,7 @@ webpack@^2.3.3: enhanced-resolve "^3.0.0" interpret "^1.0.0" json-loader "^0.5.4" + json5 "^0.5.1" loader-runner "^2.3.0" loader-utils "^0.2.16" memory-fs "~0.4.1" @@ -5809,7 +5860,7 @@ webpack@^2.3.3: source-map "^0.5.3" supports-color "^3.1.0" tapable "~0.2.5" - uglify-js "^2.8.5" + uglify-js "^2.8.27" watchpack "^1.3.1" webpack-sources "^0.2.3" yargs "^6.0.0" @@ -5898,6 +5949,13 @@ ws@1.1.1: options ">=0.0.5" ultron "1.0.x" +ws@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-2.3.1.tgz#6b94b3e447cb6a363f785eaf94af6359e8e81c80" + dependencies: + safe-buffer "~5.0.1" + ultron "~1.1.0" + wtf-8@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/wtf-8/-/wtf-8-1.0.0.tgz#392d8ba2d0f1c34d1ee2d630f15d0efb68e1048a" |