diff options
author | Phil Hughes <me@iamphill.com> | 2017-04-28 14:46:58 +0100 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2017-04-28 14:46:58 +0100 |
commit | 79e2a524e22e5474c5b33deb81ac6cc51512d2e5 (patch) | |
tree | f863a03eb4ab1e21f67bd22cae1c1c153f8d3916 | |
parent | b5b33200ccef223e089edf6ad8cdd0ecc3e737da (diff) | |
parent | 31b87b8cab2c2daebb99657046e5d121b48499ad (diff) | |
download | gitlab-ce-79e2a524e22e5474c5b33deb81ac6cc51512d2e5.tar.gz |
Merge branch 'master' into deploy-keys-load-async
117 files changed, 2700 insertions, 408 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 230ca698ad0..e911d7e5b89 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -68,6 +68,13 @@ stages: - //@gitlab-org/gitlab-ee - //@gitlab/gitlab-ee +# Skip all jobs except the ones that begin with 'docs/'. +# Used for commits including ONLY documentation changes. +# https://docs.gitlab.com/ce/development/writing_documentation.html#testing +.except-docs: &except-docs + except: + - /^docs\/.*/ + .rspec-knapsack: &rspec-knapsack stage: test <<: *dedicated-runner @@ -91,11 +98,13 @@ stages: .rspec-knapsack-pg: &rspec-knapsack-pg <<: *rspec-knapsack <<: *use-pg + <<: *except-docs .rspec-knapsack-mysql: &rspec-knapsack-mysql <<: *rspec-knapsack <<: *use-mysql <<: *only-master-and-ee-or-mysql + <<: *except-docs .spinach-knapsack: &spinach-knapsack stage: test @@ -120,16 +129,19 @@ stages: .spinach-knapsack-pg: &spinach-knapsack-pg <<: *spinach-knapsack <<: *use-pg + <<: *except-docs .spinach-knapsack-mysql: &spinach-knapsack-mysql <<: *spinach-knapsack <<: *use-mysql <<: *only-master-and-ee-or-mysql + <<: *except-docs # Prepare and merge knapsack tests knapsack: <<: *knapsack-state <<: *dedicated-runner + <<: *except-docs stage: prepare script: - mkdir -p knapsack/${CI_PROJECT_NAME}/ @@ -156,6 +168,7 @@ update-knapsack: setup-test-env: <<: *use-pg <<: *dedicated-runner + <<: *except-docs stage: prepare script: - node --version @@ -243,6 +256,7 @@ spinach mysql 9 10: *spinach-knapsack-mysql .exec: &exec <<: *ruby-static-analysis <<: *dedicated-runner + <<: *except-docs stage: test script: - bundle exec $CI_JOB_NAME @@ -250,6 +264,7 @@ spinach mysql 9 10: *spinach-knapsack-mysql rubocop: <<: *ruby-static-analysis <<: *dedicated-runner + <<: *except-docs stage: test script: - bundle exec "rubocop --require rubocop-rspec" @@ -266,6 +281,7 @@ rake downtime_check: - master - tags - /^[\d-]+-stable(-ee)?$/ + - /^docs\/*/ rake ee_compat_check: <<: *exec @@ -296,10 +312,12 @@ rake ee_compat_check: rake pg db:migrate:reset: <<: *db-migrate-reset <<: *use-pg + <<: *except-docs rake mysql db:migrate:reset: <<: *db-migrate-reset <<: *use-mysql + <<: *except-docs .db-rollback: &db-rollback stage: test @@ -311,10 +329,12 @@ rake mysql db:migrate:reset: rake pg db:rollback: <<: *db-rollback <<: *use-pg + <<: *except-docs rake mysql db:rollback: <<: *db-rollback <<: *use-mysql + <<: *except-docs .db-seed_fu: &db-seed_fu stage: test @@ -336,14 +356,17 @@ rake mysql db:rollback: rake pg db:seed_fu: <<: *db-seed_fu <<: *use-pg + <<: *except-docs rake mysql db:seed_fu: <<: *db-seed_fu <<: *use-mysql + <<: *except-docs rake gitlab:assets:compile: stage: test <<: *dedicated-runner + <<: *except-docs dependencies: [] variables: NODE_ENV: "production" @@ -367,6 +390,7 @@ rake karma: stage: test <<: *use-pg <<: *dedicated-runner + <<: *except-docs variables: BABEL_ENV: "coverage" script: @@ -403,13 +427,6 @@ docs:check:links: # Check the internal links - bundle exec nanoc check internal_links -bundler:check: - stage: test - <<: *dedicated-runner - <<: *ruby-static-analysis - script: - - bundle check - bundler:audit: stage: test <<: *ruby-static-analysis @@ -454,6 +471,7 @@ coverage: stage: post-test services: [] <<: *dedicated-runner + <<: *except-docs variables: SETUP_DB: "false" USE_BUNDLE_INSTALL: "true" @@ -469,6 +487,7 @@ coverage: lint:javascript: <<: *dedicated-runner + <<: *except-docs stage: test before_script: [] script: @@ -476,6 +495,7 @@ lint:javascript: lint:javascript:report: <<: *dedicated-runner + <<: *except-docs stage: post-test before_script: [] script: diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md index 241d90a204b..66e1e0e20b3 100644 --- a/.gitlab/issue_templates/Bug.md +++ b/.gitlab/issue_templates/Bug.md @@ -1,3 +1,17 @@ +Please read this! + +Before opening a new issue, make sure to search for keywords in the issues +filtered by the "regression" or "bug" label: + +- https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=regression +- https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=bug + +and verify the issue you're about to submit isn't a duplicate. + +Please remove this notice if you're confident your issue isn't a duplicate. + +------ + ### Summary (Summarize the bug encountered concisely) @@ -56,3 +70,5 @@ logs, and code as it's very hard to read otherwise.) ### Possible fixes (If you can, link to the line of code that might be responsible for the problem) + +/label ~bug diff --git a/.gitlab/issue_templates/Feature Proposal.md b/.gitlab/issue_templates/Feature Proposal.md index 2636010e2fb..d96c9ad59e0 100644 --- a/.gitlab/issue_templates/Feature Proposal.md +++ b/.gitlab/issue_templates/Feature Proposal.md @@ -1,3 +1,16 @@ +Please read this! + +Before opening a new issue, make sure to search for keywords in the issues +filtered by the "feature proposal" label: + +- https://gitlab.com/gitlab-org/gitlab-ce/issues?label_name%5B%5D=feature+proposal + +and verify the issue you're about to submit isn't a duplicate. + +Please remove this notice if you're confident your issue isn't a duplicate. + +------ + ### Description (Include problem, use cases, benefits, and/or goals) @@ -15,3 +28,5 @@ 3. How does someone use this During implementation, this can then be copied and used as a starter for the documentation.) + +/label ~"feature proposal" diff --git a/app/assets/javascripts/blob/pdf/index.js b/app/assets/javascripts/blob/pdf/index.js index a74c2db9a61..9161be98853 100644 --- a/app/assets/javascripts/blob/pdf/index.js +++ b/app/assets/javascripts/blob/pdf/index.js @@ -31,7 +31,7 @@ export default () => { }, }, template: ` - <div class="container-fluid md prepend-top-default append-bottom-default"> + <div class="js-pdf-viewer container-fluid md prepend-top-default append-bottom-default"> <div class="text-center loading" v-if="loading && !error"> diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js new file mode 100644 index 00000000000..7efa8537298 --- /dev/null +++ b/app/assets/javascripts/blob/viewer/index.js @@ -0,0 +1,120 @@ +/* global Flash */ +export default class BlobViewer { + constructor() { + this.switcher = document.querySelector('.js-blob-viewer-switcher'); + this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn'); + this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn'); + this.simpleViewer = document.querySelector('.blob-viewer[data-type="simple"]'); + this.richViewer = document.querySelector('.blob-viewer[data-type="rich"]'); + this.$blobContentHolder = $('#blob-content-holder'); + + let initialViewerName = document.querySelector('.blob-viewer:not(.hidden)').getAttribute('data-type'); + + this.initBindings(); + + if (this.switcher && location.hash.indexOf('#L') === 0) { + initialViewerName = 'simple'; + } + + this.switchToViewer(initialViewerName); + } + + initBindings() { + if (this.switcherBtns.length) { + Array.from(this.switcherBtns) + .forEach((el) => { + el.addEventListener('click', this.switchViewHandler.bind(this)); + }); + } + + if (this.copySourceBtn) { + this.copySourceBtn.addEventListener('click', () => { + if (this.copySourceBtn.classList.contains('disabled')) return; + + this.switchToViewer('simple'); + }); + } + } + + switchViewHandler(e) { + const target = e.currentTarget; + + e.preventDefault(); + + this.switchToViewer(target.getAttribute('data-viewer')); + } + + toggleCopyButtonState() { + if (!this.copySourceBtn) return; + + if (this.simpleViewer.getAttribute('data-loaded')) { + this.copySourceBtn.setAttribute('title', 'Copy source to clipboard'); + this.copySourceBtn.classList.remove('disabled'); + } else if (this.activeViewer === this.simpleViewer) { + this.copySourceBtn.setAttribute('title', 'Wait for the source to load to copy it to the clipboard'); + this.copySourceBtn.classList.add('disabled'); + } else { + this.copySourceBtn.setAttribute('title', 'Switch to the source to copy it to the clipboard'); + this.copySourceBtn.classList.add('disabled'); + } + + $(this.copySourceBtn).tooltip('fixTitle'); + } + + loadViewer(viewerParam) { + const viewer = viewerParam; + const url = viewer.getAttribute('data-url'); + + if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) { + return; + } + + viewer.setAttribute('data-loading', 'true'); + + $.ajax({ + url, + dataType: 'JSON', + }) + .fail(() => new Flash('Error loading source view')) + .done((data) => { + viewer.innerHTML = data.html; + $(viewer).syntaxHighlight(); + + viewer.setAttribute('data-loaded', 'true'); + + this.$blobContentHolder.trigger('highlight:line'); + + this.toggleCopyButtonState(); + }); + } + + switchToViewer(name) { + const newViewer = document.querySelector(`.blob-viewer[data-type='${name}']`); + if (this.activeViewer === newViewer) return; + + const oldButton = document.querySelector('.js-blob-viewer-switch-btn.active'); + const newButton = document.querySelector(`.js-blob-viewer-switch-btn[data-viewer='${name}']`); + const oldViewer = document.querySelector(`.blob-viewer:not([data-type='${name}'])`); + + if (oldButton) { + oldButton.classList.remove('active'); + } + + if (newButton) { + newButton.classList.add('active'); + newButton.blur(); + } + + if (oldViewer) { + oldViewer.classList.add('hidden'); + } + + newViewer.classList.remove('hidden'); + + this.activeViewer = newViewer; + + this.toggleCopyButtonState(); + + this.loadViewer(newViewer); + } +} diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 2300daf0727..f7402792c59 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -49,6 +49,7 @@ import UserCallout from './user_callout'; import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; import ShortcutsWiki from './shortcuts_wiki'; import SettingsDeployKeys from './settings/settings_repository'; +import BlobViewer from './blob/viewer/index'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -300,6 +301,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); gl.TargetBranchDropDown.bootstrap(); break; case 'projects:blob:show': + new BlobViewer(); gl.TargetBranchDropDown.bootstrap(); initBlob(); break; diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index 1821ca18053..a6f7bea99f5 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -41,7 +41,6 @@ require('vendor/jquery.scrollTo'); LineHighlighter.prototype._hash = ''; function LineHighlighter(hash) { - var range; if (hash == null) { // Initialize a LineHighlighter object // @@ -51,10 +50,22 @@ require('vendor/jquery.scrollTo'); this.setHash = bind(this.setHash, this); this.highlightLine = bind(this.highlightLine, this); this.clickHandler = bind(this.clickHandler, this); + this.highlightHash = this.highlightHash.bind(this); this._hash = hash; this.bindEvents(); - if (hash !== '') { - range = this.hashToRange(hash); + this.highlightHash(); + } + + LineHighlighter.prototype.bindEvents = function() { + const $blobContentHolder = $('#blob-content-holder'); + $blobContentHolder.on('click', 'a[data-line-number]', this.clickHandler); + $blobContentHolder.on('highlight:line', this.highlightHash); + }; + + LineHighlighter.prototype.highlightHash = function() { + var range; + if (this._hash !== '') { + range = this.hashToRange(this._hash); if (range[0]) { this.highlightRange(range); $.scrollTo("#L" + range[0], { @@ -64,10 +75,6 @@ require('vendor/jquery.scrollTo'); }); } } - } - - LineHighlighter.prototype.bindEvents = function() { - $('#blob-content-holder').on('click', 'a[data-line-number]', this.clickHandler); }; LineHighlighter.prototype.clickHandler = function(event) { diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index a668a6c4c39..80691a234f8 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -120,6 +120,10 @@ // Ensure that image does not exceed viewport max-height: calc(100vh - 100px); } + + table { + @include markdown-table; + } } .toolbar-group { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index b3340d41333..3a98332e46c 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -13,6 +13,13 @@ } /* + * Mixin for markdown tables + */ +@mixin markdown-table { + width: auto; +} + +/* * Base mixin for lists in GitLab */ @mixin basic-list { diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 411f1c4442b..724b4080ee0 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -200,6 +200,7 @@ .header-content { flex: 1; + line-height: 1.8; a { color: $gl-text-color; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index b2f45625a2a..2aa52986e0a 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -101,11 +101,16 @@ ul.related-merge-requests > li { } } -.merge-request-ci-status { +.merge-request-ci-status, +.related-merge-requests { + .ci-status-link { + display: block; + margin-top: 3px; + margin-right: 5px; + } + svg { - margin-right: 4px; - position: relative; - top: 1px; + display: block; } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 69a95db6920..7cf74502a3a 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -97,6 +97,10 @@ ul.notes { padding-left: 1.3em; } } + + table { + @include markdown-table; + } } } diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index 9bc47bbe173..04ff2d52b91 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -159,3 +159,9 @@ ul.wiki-pages-list.content-list { padding: 5px 0; } } + +.wiki { + table { + @include markdown-table; + } +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e77094fe2a8..e48f0963ef4 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -118,6 +118,10 @@ class ApplicationController < ActionController::Base end end + def respond_422 + head :unprocessable_entity + end + def no_cache_headers response.headers["Cache-Control"] = "no-cache, no-store, max-age=0, must-revalidate" response.headers["Pragma"] = "no-cache" diff --git a/app/controllers/concerns/renders_blob.rb b/app/controllers/concerns/renders_blob.rb new file mode 100644 index 00000000000..d478c3bb6ca --- /dev/null +++ b/app/controllers/concerns/renders_blob.rb @@ -0,0 +1,17 @@ +module RendersBlob + extend ActiveSupport::Concern + + def render_blob_json(blob) + viewer = + if params[:viewer] == 'rich' + blob.rich_viewer + else + blob.simple_viewer + end + return render_404 unless viewer + + render json: { + html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_asynchronously: false) + } + end +end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 9fce1db6742..be5822b2cd4 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -2,6 +2,7 @@ class Projects::BlobController < Projects::ApplicationController include ExtractsPath include CreatesCommit + include RendersBlob include ActionView::Helpers::SanitizeHelper # Raised when given an invalid file path @@ -34,8 +35,20 @@ class Projects::BlobController < Projects::ApplicationController end def show - environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } - @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last + @blob.override_max_size! if params[:override_max_size] == 'true' + + respond_to do |format| + format.html do + environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } + @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last + + render 'show' + end + + format.json do + render_blob_json(@blob) + end + end end def edit @@ -96,7 +109,7 @@ class Projects::BlobController < Projects::ApplicationController private def blob - @blob ||= Blob.decorate(@repository.blob_at(@commit.id, @path)) + @blob ||= Blob.decorate(@repository.blob_at(@commit.id, @path), @project) if @blob @blob diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index 04e8cdf6256..e24fc45d166 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -1,6 +1,6 @@ class Projects::BuildsController < Projects::ApplicationController before_action :build, except: [:index, :cancel_all] - before_action :authorize_read_build!, except: [:cancel, :cancel_all, :retry, :play] + before_action :authorize_read_build!, only: [:index, :show, :status, :raw, :trace] before_action :authorize_update_build!, except: [:index, :show, :status, :raw, :trace] layout 'project' @@ -60,20 +60,22 @@ class Projects::BuildsController < Projects::ApplicationController end def retry - return render_404 unless @build.retryable? + return respond_422 unless @build.retryable? build = Ci::Build.retry(@build, current_user) redirect_to build_path(build) end def play - return render_404 unless @build.playable? + 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 @@ -85,9 +87,12 @@ class Projects::BuildsController < Projects::ApplicationController end def erase - @build.erase(erased_by: current_user) - redirect_to namespace_project_build_path(project.namespace, project, @build), + 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 end def raw diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index c55b37ae0dd..a0b08ad130f 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -15,7 +15,7 @@ class Projects::RawController < Projects::ApplicationController return if cached_blob? - if @blob.lfs_pointer? && project.lfs_enabled? + if @blob.valid_lfs_pointer? send_lfs_object else send_git_blob @repository, @blob diff --git a/app/controllers/unicorn_test_controller.rb b/app/controllers/unicorn_test_controller.rb new file mode 100644 index 00000000000..b7a1a046be0 --- /dev/null +++ b/app/controllers/unicorn_test_controller.rb @@ -0,0 +1,12 @@ +if Rails.env.test? + class UnicornTestController < ActionController::Base + def pid + render plain: Process.pid.to_s + end + + def kill + Process.kill(params[:signal], Process.pid) + render plain: 'Bye!' + end + end +end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 36b16421e8f..cc47654dc06 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -52,7 +52,7 @@ module BlobHelper if !on_top_of_branch?(project, ref) button_tag label, class: "#{common_classes} disabled has-tooltip", title: "You can only #{action} files when you are on a branch", data: { container: 'body' } - elsif blob.lfs_pointer? + elsif blob.valid_lfs_pointer? button_tag label, class: "#{common_classes} disabled has-tooltip", title: "It is not possible to #{action} files that are stored in LFS using the web interface", data: { container: 'body' } elsif can_modify_blob?(blob, project, ref) button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal' @@ -95,7 +95,7 @@ module BlobHelper end def can_modify_blob?(blob, project = @project, ref = @ref) - !blob.lfs_pointer? && can_edit_tree?(project, ref) + !blob.valid_lfs_pointer? && can_edit_tree?(project, ref) end def leave_edit_message @@ -118,28 +118,15 @@ module BlobHelper icon("#{file_type_icon_class('file', mode, name)} fw") end - def blob_text_viewable?(blob) - blob && blob.text? && !blob.lfs_pointer? && !blob.only_display_raw? - end - - def blob_rendered_as_text?(blob) - blob_text_viewable?(blob) && blob.to_partial_path(@project) == 'text' - end - - def blob_size(blob) - if blob.lfs_pointer? - blob.lfs_size - else - blob.size - end + def blob_raw_url + namespace_project_raw_path(@project.namespace, @project, @id) end # SVGs can contain malicious JavaScript; only include whitelisted # elements and attributes. Note that this whitelist is by no means complete # and may omit some elements. - def sanitize_svg(blob) - blob.data = Gitlab::Sanitizers::SVG.clean(blob.data) - blob + def sanitize_svg_data(data) + Gitlab::Sanitizers::SVG.clean(data) end # If we blindly set the 'real' content type when serving a Git blob we @@ -221,13 +208,42 @@ module BlobHelper clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard') end - def copy_blob_content_button(blob) - return if markup?(blob.name) - - clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm", title: "Copy content to clipboard") + def copy_blob_source_button(blob) + clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm js-copy-blob-source-btn", title: "Copy source to clipboard") end def open_raw_file_button(path) link_to icon('file-code-o'), path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: 'Open raw', data: { container: 'body' } end + + def blob_render_error_reason(viewer) + case viewer.render_error + when :too_large + max_size = + if viewer.absolutely_too_large? + viewer.absolute_max_size + elsif viewer.too_large? + viewer.max_size + end + "it is larger than #{number_to_human_size(max_size)}" + when :server_side_but_stored_in_lfs + "it is stored in LFS" + end + end + + def blob_render_error_options(viewer) + options = [] + + if viewer.render_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))) + end + + if viewer.rich? && viewer.blob.rendered_as_text? + options << link_to('view the source', '#', class: 'js-blob-viewer-switch-btn', data: { viewer: 'simple' }) + end + + options << link_to('download it', blob_raw_url, target: '_blank', rel: 'noopener noreferrer') + + options + end end diff --git a/app/models/blob.rb b/app/models/blob.rb index 55872acef51..290df5d5520 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -3,8 +3,40 @@ class Blob < SimpleDelegator CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour - # The maximum size of an SVG that can be displayed. - MAXIMUM_SVG_SIZE = 2.megabytes + MAXIMUM_TEXT_HIGHLIGHT_SIZE = 1.megabyte + + # Finding a viewer for a blob happens based only on extension and whether the + # blob is binary or text, which means 1 blob should only be matched by 1 viewer, + # and the order of these viewers doesn't really matter. + # + # However, when the blob is an LFS pointer, we cannot know for sure whether the + # file being pointed to is binary or text. In this case, we match only on + # extension, preferring binary viewers over text ones if both exist, since the + # large files referred to in "Large File Storage" are much more likely to be + # binary than text. + # + # `.stl` files, for example, exist in both binary and text forms, and are + # handled by different viewers (`BinarySTL` and `TextSTL`) depending on blob + # type. LFS pointers to `.stl` files are assumed to always be the binary kind, + # and use the `BinarySTL` viewer. + RICH_VIEWERS = [ + BlobViewer::Markup, + BlobViewer::Notebook, + BlobViewer::SVG, + + BlobViewer::Image, + BlobViewer::Sketch, + + BlobViewer::PDF, + + BlobViewer::BinarySTL, + BlobViewer::TextSTL, + ].freeze + + BINARY_VIEWERS = RICH_VIEWERS.select(&:binary?).freeze + TEXT_VIEWERS = RICH_VIEWERS.select(&:text?).freeze + + attr_reader :project # Wrap a Gitlab::Git::Blob object, or return nil when given nil # @@ -16,10 +48,16 @@ class Blob < SimpleDelegator # # blob = Blob.decorate(nil) # puts "truthy" if blob # No output - def self.decorate(blob) + def self.decorate(blob, project = nil) return if blob.nil? - new(blob) + new(blob, project) + end + + def initialize(blob, project = nil) + @project = project + + super(blob) end # Returns the data of the blob. @@ -35,82 +73,107 @@ class Blob < SimpleDelegator end def no_highlighting? - size && size > 1.megabyte + size && size > MAXIMUM_TEXT_HIGHLIGHT_SIZE end - def only_display_raw? + def too_large? size && truncated? end + # Returns the size of the file that this blob represents. If this blob is an + # LFS pointer, this is the size of the file stored in LFS. Otherwise, this is + # the size of the blob itself. + def raw_size + if valid_lfs_pointer? + lfs_size + else + size + end + end + + # Returns whether the file that this blob represents is binary. If this blob is + # an LFS pointer, we assume the file stored in LFS is binary, unless a + # text-based rich blob viewer matched on the file's extension. Otherwise, this + # depends on the type of the blob itself. + def raw_binary? + if valid_lfs_pointer? + if rich_viewer + rich_viewer.binary? + else + true + end + else + binary? + end + end + def extension - extname.downcase.delete('.') + @extension ||= extname.downcase.delete('.') end - def svg? - text? && language && language.name == 'SVG' + def video? + UploaderHelper::VIDEO_EXT.include?(extension) end - def pdf? - extension == 'pdf' + def readable_text? + text? && !valid_lfs_pointer? && !too_large? end - def ipython_notebook? - text? && language&.name == 'Jupyter Notebook' + def valid_lfs_pointer? + lfs_pointer? && project&.lfs_enabled? end - def sketch? - binary? && extension == 'sketch' + def invalid_lfs_pointer? + lfs_pointer? && !project&.lfs_enabled? end - def stl? - extension == 'stl' + def simple_viewer + @simple_viewer ||= simple_viewer_class.new(self) end - def markup? - text? && Gitlab::MarkupHelper.markup?(name) + def rich_viewer + return @rich_viewer if defined?(@rich_viewer) + + @rich_viewer = rich_viewer_class&.new(self) end - def size_within_svg_limits? - size <= MAXIMUM_SVG_SIZE + def rendered_as_text?(ignore_errors: true) + simple_viewer.text? && (ignore_errors || simple_viewer.render_error.nil?) end - def video? - UploaderHelper::VIDEO_EXT.include?(extname.downcase.delete('.')) + def show_viewer_switcher? + rendered_as_text? && rich_viewer end - def to_partial_path(project) - if lfs_pointer? - if project.lfs_enabled? - 'download' - else - 'text' - end - elsif image? - 'image' - elsif svg? - 'svg' - elsif pdf? - 'pdf' - elsif ipython_notebook? - 'notebook' - elsif sketch? - 'sketch' - elsif stl? - 'stl' - elsif markup? - if only_display_raw? - 'too_large' - else - 'markup' - end - elsif text? - if only_display_raw? - 'too_large' - else - 'text' - end - else - 'download' + def override_max_size! + simple_viewer&.override_max_size = true + rich_viewer&.override_max_size = true + end + + private + + def simple_viewer_class + if empty? + BlobViewer::Empty + elsif raw_binary? + BlobViewer::Download + else # text + BlobViewer::Text end end + + def rich_viewer_class + return if invalid_lfs_pointer? || empty? + + classes = + if valid_lfs_pointer? + BINARY_VIEWERS + TEXT_VIEWERS + elsif binary? + BINARY_VIEWERS + else # text + TEXT_VIEWERS + end + + classes.find { |viewer_class| viewer_class.can_render?(self) } + end end diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb new file mode 100644 index 00000000000..f944b00c9d3 --- /dev/null +++ b/app/models/blob_viewer/base.rb @@ -0,0 +1,96 @@ +module BlobViewer + class Base + class_attribute :partial_name, :type, :extensions, :client_side, :binary, :switcher_icon, :switcher_title, :max_size, :absolute_max_size + + delegate :partial_path, :rich?, :simple?, :client_side?, :server_side?, :text?, :binary?, to: :class + + attr_reader :blob + attr_accessor :override_max_size + + def initialize(blob) + @blob = blob + end + + def self.partial_path + "projects/blob/viewers/#{partial_name}" + end + + def self.rich? + type == :rich + end + + def self.simple? + type == :simple + end + + def self.client_side? + client_side + end + + def self.server_side? + !client_side? + end + + def self.binary? + binary + end + + def self.text? + !binary? + end + + def self.can_render?(blob) + !extensions || extensions.include?(blob.extension) + end + + def too_large? + blob.raw_size > max_size + end + + def absolutely_too_large? + blob.raw_size > absolute_max_size + end + + def can_override_max_size? + too_large? && !absolutely_too_large? + end + + # This method is used on the server side to check whether we can attempt to + # render the blob at all. Human-readable error messages are found in the + # `BlobHelper#blob_render_error_reason` helper. + # + # This method does not and should not load the entire blob contents into + # memory, and should not be overridden to do so in order to validate the + # format of the blob. + # + # Prefer to implement a client-side viewer, where the JS component loads the + # binary from `blob_raw_url` and does its own format validation and error + # rendering, especially for potentially large binary formats. + def render_error + return @render_error if defined?(@render_error) + + @render_error = + if server_side_but_stored_in_lfs? + # Files stored in LFS can only be rendered using a client-side viewer, + # since we do not want to read large amounts of data into memory on the + # server side. Client-side viewers use JS and can fetch the file from + # `blob_raw_url` using AJAX. + :server_side_but_stored_in_lfs + elsif override_max_size ? absolutely_too_large? : too_large? + :too_large + end + end + + def prepare! + if server_side? && blob.project + blob.load_all_data!(blob.project.repository) + end + end + + private + + def server_side_but_stored_in_lfs? + server_side? && blob.valid_lfs_pointer? + end + end +end diff --git a/app/models/blob_viewer/binary_stl.rb b/app/models/blob_viewer/binary_stl.rb new file mode 100644 index 00000000000..80393471ef2 --- /dev/null +++ b/app/models/blob_viewer/binary_stl.rb @@ -0,0 +1,10 @@ +module BlobViewer + class BinarySTL < Base + include Rich + include ClientSide + + self.partial_name = 'stl' + self.extensions = %w(stl) + self.binary = true + end +end diff --git a/app/models/blob_viewer/client_side.rb b/app/models/blob_viewer/client_side.rb new file mode 100644 index 00000000000..42ec68f864b --- /dev/null +++ b/app/models/blob_viewer/client_side.rb @@ -0,0 +1,11 @@ +module BlobViewer + module ClientSide + extend ActiveSupport::Concern + + included do + self.client_side = true + self.max_size = 10.megabytes + self.absolute_max_size = 50.megabytes + end + end +end diff --git a/app/models/blob_viewer/download.rb b/app/models/blob_viewer/download.rb new file mode 100644 index 00000000000..adc06587f69 --- /dev/null +++ b/app/models/blob_viewer/download.rb @@ -0,0 +1,17 @@ +module BlobViewer + class Download < Base + include Simple + # We treat the Download viewer as if it renders the content client-side, + # so that it doesn't attempt to load the entire blob contents and is + # rendered synchronously instead of loaded asynchronously. + include ClientSide + + self.partial_name = 'download' + self.binary = true + + # We can always render the Download viewer, even if the blob is in LFS or too large. + def render_error + nil + end + end +end diff --git a/app/models/blob_viewer/empty.rb b/app/models/blob_viewer/empty.rb new file mode 100644 index 00000000000..d9d128eb273 --- /dev/null +++ b/app/models/blob_viewer/empty.rb @@ -0,0 +1,9 @@ +module BlobViewer + class Empty < Base + include Simple + include ServerSide + + self.partial_name = 'empty' + self.binary = true + end +end diff --git a/app/models/blob_viewer/image.rb b/app/models/blob_viewer/image.rb new file mode 100644 index 00000000000..c4eae5c79c2 --- /dev/null +++ b/app/models/blob_viewer/image.rb @@ -0,0 +1,12 @@ +module BlobViewer + class Image < Base + include Rich + include ClientSide + + self.partial_name = 'image' + self.extensions = UploaderHelper::IMAGE_EXT + self.binary = true + self.switcher_icon = 'picture-o' + self.switcher_title = 'image' + end +end diff --git a/app/models/blob_viewer/markup.rb b/app/models/blob_viewer/markup.rb new file mode 100644 index 00000000000..8fdbab30dd1 --- /dev/null +++ b/app/models/blob_viewer/markup.rb @@ -0,0 +1,10 @@ +module BlobViewer + class Markup < Base + include Rich + include ServerSide + + self.partial_name = 'markup' + self.extensions = Gitlab::MarkupHelper::EXTENSIONS + self.binary = false + end +end diff --git a/app/models/blob_viewer/notebook.rb b/app/models/blob_viewer/notebook.rb new file mode 100644 index 00000000000..8632b8a9885 --- /dev/null +++ b/app/models/blob_viewer/notebook.rb @@ -0,0 +1,12 @@ +module BlobViewer + class Notebook < Base + include Rich + include ClientSide + + self.partial_name = 'notebook' + self.extensions = %w(ipynb) + self.binary = false + self.switcher_icon = 'file-text-o' + self.switcher_title = 'notebook' + end +end diff --git a/app/models/blob_viewer/pdf.rb b/app/models/blob_viewer/pdf.rb new file mode 100644 index 00000000000..65805f5f388 --- /dev/null +++ b/app/models/blob_viewer/pdf.rb @@ -0,0 +1,12 @@ +module BlobViewer + class PDF < Base + include Rich + include ClientSide + + self.partial_name = 'pdf' + self.extensions = %w(pdf) + self.binary = true + self.switcher_icon = 'file-pdf-o' + self.switcher_title = 'PDF' + end +end diff --git a/app/models/blob_viewer/rich.rb b/app/models/blob_viewer/rich.rb new file mode 100644 index 00000000000..be373dbc948 --- /dev/null +++ b/app/models/blob_viewer/rich.rb @@ -0,0 +1,11 @@ +module BlobViewer + module Rich + extend ActiveSupport::Concern + + included do + self.type = :rich + self.switcher_icon = 'file-text-o' + self.switcher_title = 'rendered file' + end + end +end diff --git a/app/models/blob_viewer/server_side.rb b/app/models/blob_viewer/server_side.rb new file mode 100644 index 00000000000..899107d02ea --- /dev/null +++ b/app/models/blob_viewer/server_side.rb @@ -0,0 +1,11 @@ +module BlobViewer + module ServerSide + extend ActiveSupport::Concern + + included do + self.client_side = false + self.max_size = 2.megabytes + self.absolute_max_size = 5.megabytes + end + end +end diff --git a/app/models/blob_viewer/simple.rb b/app/models/blob_viewer/simple.rb new file mode 100644 index 00000000000..454a20495fc --- /dev/null +++ b/app/models/blob_viewer/simple.rb @@ -0,0 +1,11 @@ +module BlobViewer + module Simple + extend ActiveSupport::Concern + + included do + self.type = :simple + self.switcher_icon = 'code' + self.switcher_title = 'source' + end + end +end diff --git a/app/models/blob_viewer/sketch.rb b/app/models/blob_viewer/sketch.rb new file mode 100644 index 00000000000..818456778e1 --- /dev/null +++ b/app/models/blob_viewer/sketch.rb @@ -0,0 +1,12 @@ +module BlobViewer + class Sketch < Base + include Rich + include ClientSide + + self.partial_name = 'sketch' + self.extensions = %w(sketch) + self.binary = true + self.switcher_icon = 'file-image-o' + self.switcher_title = 'preview' + end +end diff --git a/app/models/blob_viewer/svg.rb b/app/models/blob_viewer/svg.rb new file mode 100644 index 00000000000..b7e5cd71e6b --- /dev/null +++ b/app/models/blob_viewer/svg.rb @@ -0,0 +1,12 @@ +module BlobViewer + class SVG < Base + include Rich + include ServerSide + + self.partial_name = 'svg' + self.extensions = %w(svg) + self.binary = false + self.switcher_icon = 'picture-o' + self.switcher_title = 'image' + end +end diff --git a/app/models/blob_viewer/text.rb b/app/models/blob_viewer/text.rb new file mode 100644 index 00000000000..e27b2c2b493 --- /dev/null +++ b/app/models/blob_viewer/text.rb @@ -0,0 +1,11 @@ +module BlobViewer + class Text < Base + include Simple + include ServerSide + + self.partial_name = 'text' + self.binary = false + self.max_size = 1.megabyte + self.absolute_max_size = 10.megabytes + end +end diff --git a/app/models/blob_viewer/text_stl.rb b/app/models/blob_viewer/text_stl.rb new file mode 100644 index 00000000000..8184dc0104c --- /dev/null +++ b/app/models/blob_viewer/text_stl.rb @@ -0,0 +1,5 @@ +module BlobViewer + class TextSTL < BinarySTL + self.binary = false + end +end diff --git a/app/models/commit.rb b/app/models/commit.rb index 8b8b3f00202..bb4cb8efd15 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -316,7 +316,7 @@ class Commit def uri_type(path) entry = @raw.tree.path(path) if entry[:type] == :blob - blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name])) + blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]), @project) blob.image? || blob.video? ? :raw : :blob else entry[:type] diff --git a/app/models/label.rb b/app/models/label.rb index d8b0e250732..ddddb6bdf8f 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -34,6 +34,7 @@ class Label < ActiveRecord::Base scope :templates, -> { where(template: true) } scope :with_title, ->(title) { where(title: title) } + scope :on_project_boards, ->(project_id) { joins(lists: :board).merge(List.movable).where(boards: { project_id: project_id }) } def self.prioritized(project) joins(:priorities) diff --git a/app/models/repository.rb b/app/models/repository.rb index e74edb8e6f7..d02aea49689 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -450,7 +450,7 @@ class Repository def blob_at(sha, path) unless Gitlab::Git.blank_ref?(sha) - Blob.decorate(Gitlab::Git::Blob.find(self, sha, path)) + Blob.decorate(Gitlab::Git::Blob.find(self, sha, path), project) end rescue Gitlab::Git::Repository::NoRepository nil diff --git a/app/models/user.rb b/app/models/user.rb index 774d4caa806..bd9c9f99663 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1068,11 +1068,13 @@ class User < ActiveRecord::Base User.find_by_email(s) end - scope.create( + user = scope.build( username: username, email: email, &creation_block ) + user.save(validate: false) + user ensure Gitlab::ExclusiveLease.cancel(lease_key, uuid) end diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index d5735f13c1e..e73b1a4361a 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -61,7 +61,7 @@ module Boards if moving_to_list.movable? moving_from_list.label_id else - project.boards.joins(:lists).merge(List.movable).pluck(:label_id) + Label.on_project_boards(project.id).pluck(:label_id) end Array(label_ids).compact diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb index 49d45ec9dbd..6aeebc26685 100644 --- a/app/services/slash_commands/interpret_service.rb +++ b/app/services/slash_commands/interpret_service.rb @@ -330,6 +330,28 @@ module SlashCommands @updates[:target_branch] = branch_name if project.repository.branch_names.include?(branch_name) end + desc 'Move issue from one column of the board to another' + params '~"Target column"' + condition do + issuable.is_a?(Issue) && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) && + issuable.project.boards.count == 1 + end + command :board_move do |target_list_name| + label_ids = find_label_ids(target_list_name) + + if label_ids.size == 1 + label_id = label_ids.first + + # Ensure this label corresponds to a list on the board + next unless Label.on_project_boards(issuable.project_id).where(id: label_id).exists? + + @updates[:remove_label_ids] = + issuable.labels.on_project_boards(issuable.project_id).where.not(id: label_id).pluck(:id) + @updates[:add_label_ids] = [label_id] + end + end + def find_label_ids(labels_param) label_ids_by_reference = extract_references(labels_param, :label).map(&:id) labels_ids_by_name = LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute.select(:id) diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml index c6b1db17f91..02eb7c8462c 100644 --- a/app/views/notify/repository_push_email.html.haml +++ b/app/views/notify/repository_push_email.html.haml @@ -74,7 +74,7 @@ - else %hr - blob = diff_file.blob - - if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob) + - if blob && blob.readable_text? %table.code.white = render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, plain: true, email: true } - else diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 9aafff343f0..3f12d64d044 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -26,9 +26,4 @@ %article.file-holder = render "projects/blob/header", blob: blob - - if blob.empty? - .file-content.code - .nothing-here-block - Empty file - - else - = render blob.to_partial_path(@project), blob: blob + = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/blob/_content.html.haml b/app/views/projects/blob/_content.html.haml new file mode 100644 index 00000000000..7afbd85cd6d --- /dev/null +++ b/app/views/projects/blob/_content.html.haml @@ -0,0 +1,8 @@ +- simple_viewer = blob.simple_viewer +- rich_viewer = blob.rich_viewer +- rich_viewer_active = rich_viewer && params[:viewer] != 'simple' + += render 'projects/blob/viewer', viewer: simple_viewer, hidden: rich_viewer_active + +- if rich_viewer + = render 'projects/blob/viewer', viewer: rich_viewer, hidden: !rich_viewer_active diff --git a/app/views/projects/blob/_download.html.haml b/app/views/projects/blob/_download.html.haml deleted file mode 100644 index 7908fcae3de..00000000000 --- a/app/views/projects/blob/_download.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -.file-content.blob_file.blob-no-preview - .center - = link_to namespace_project_raw_path(@project.namespace, @project, @id) do - %h1.light - %i.fa.fa-download - %h4 - Download (#{number_to_human_size blob_size(blob)}) diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index c553db84ee0..b89cd460455 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -9,17 +9,19 @@ = copy_file_path_button(blob.path) %small - = number_to_human_size(blob_size(blob)) + = number_to_human_size(blob.raw_size) .file-actions.hidden-xs + = render 'projects/blob/viewer_switcher', blob: blob unless blame + .btn-group{ role: "group" }< - = copy_blob_content_button(blob) if !blame && blob_rendered_as_text?(blob) + = copy_blob_source_button(blob) if !blame && blob.rendered_as_text?(ignore_errors: false) = open_raw_file_button(namespace_project_raw_path(@project.namespace, @project, @id)) = view_on_environment_button(@commit.sha, @path, @environment) if @environment .btn-group{ role: "group" }< -# only show normal/blame view links for text files - - if blob_text_viewable?(blob) + - if blob.readable_text? - if blame = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id), class: 'btn btn-sm' @@ -34,7 +36,7 @@ tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url' .btn-group{ role: "group" }< - = edit_blob_link if blob_text_viewable?(blob) + = edit_blob_link if blob.readable_text? - if current_user = replace_blob_link = delete_blob_link diff --git a/app/views/projects/blob/_image.html.haml b/app/views/projects/blob/_image.html.haml deleted file mode 100644 index 73877d730f5..00000000000 --- a/app/views/projects/blob/_image.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -.file-content.image_file - %img{ src: namespace_project_raw_path(@project.namespace, @project, @id), alt: blob.name } diff --git a/app/views/projects/blob/_render_error.html.haml b/app/views/projects/blob/_render_error.html.haml new file mode 100644 index 00000000000..9eef6cafd04 --- /dev/null +++ b/app/views/projects/blob/_render_error.html.haml @@ -0,0 +1,7 @@ +.file-content.code + .nothing-here-block + The #{viewer.switcher_title} could not be displayed because #{blob_render_error_reason(viewer)}. + + You can + = blob_render_error_options(viewer).to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe + instead. diff --git a/app/views/projects/blob/_svg.html.haml b/app/views/projects/blob/_svg.html.haml deleted file mode 100644 index 93be58fc658..00000000000 --- a/app/views/projects/blob/_svg.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -- if blob.size_within_svg_limits? - -# We need to scrub SVG but we cannot do so in the RawController: it would - -# be wrong/strange if RawController modified the data. - - blob.load_all_data!(@repository) - - blob = sanitize_svg(blob) - .file-content.image_file - %img{ src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}", alt: blob.name } -- else - = render 'too_large' diff --git a/app/views/projects/blob/_text.html.haml b/app/views/projects/blob/_text.html.haml deleted file mode 100644 index 20638f6961d..00000000000 --- a/app/views/projects/blob/_text.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -- blob.load_all_data!(@repository) -= render 'shared/file_highlight', blob: blob, repository: @repository diff --git a/app/views/projects/blob/_too_large.html.haml b/app/views/projects/blob/_too_large.html.haml deleted file mode 100644 index a505f87df40..00000000000 --- a/app/views/projects/blob/_too_large.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -.file-content.code - .nothing-here-block - The file could not be displayed as it is too large, you can - #{link_to('view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank', rel: 'noopener noreferrer')} - instead. diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml new file mode 100644 index 00000000000..5326bb3e0cf --- /dev/null +++ b/app/views/projects/blob/_viewer.html.haml @@ -0,0 +1,14 @@ +- hidden = local_assigns.fetch(:hidden, false) +- render_error = viewer.render_error +- load_asynchronously = local_assigns.fetch(:load_asynchronously, viewer.server_side?) && render_error.nil? + +- url = url_for(params.merge(viewer: viewer.type, format: :json)) if load_asynchronously +.blob-viewer{ data: { type: viewer.type, url: url }, class: ('hidden' if hidden) } + - if load_asynchronously + .text-center.prepend-top-default.append-bottom-default + = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content') + - elsif render_error + = render 'projects/blob/render_error', viewer: viewer + - else + - viewer.prepare! + = render viewer.partial_path, viewer: viewer diff --git a/app/views/projects/blob/_viewer_switcher.html.haml b/app/views/projects/blob/_viewer_switcher.html.haml new file mode 100644 index 00000000000..6a521069418 --- /dev/null +++ b/app/views/projects/blob/_viewer_switcher.html.haml @@ -0,0 +1,12 @@ +- if blob.show_viewer_switcher? + - simple_viewer = blob.simple_viewer + - rich_viewer = blob.rich_viewer + + .btn-group.js-blob-viewer-switcher{ role: "group" } + - simple_label = "Display #{simple_viewer.switcher_title}" + %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }> + = icon(simple_viewer.switcher_icon) + + - rich_label = "Display #{rich_viewer.switcher_title}" + %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }> + = icon(rich_viewer.switcher_icon) diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index b9b3f3ec7a3..67f57b5e4b9 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -2,6 +2,9 @@ - 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' diff --git a/app/views/projects/blob/viewers/_download.html.haml b/app/views/projects/blob/viewers/_download.html.haml new file mode 100644 index 00000000000..684240d02c7 --- /dev/null +++ b/app/views/projects/blob/viewers/_download.html.haml @@ -0,0 +1,7 @@ +.file-content.blob_file.blob-no-preview + .center + = link_to blob_raw_url do + %h1.light + = icon('download') + %h4 + Download (#{number_to_human_size(viewer.blob.raw_size)}) diff --git a/app/views/projects/blob/viewers/_empty.html.haml b/app/views/projects/blob/viewers/_empty.html.haml new file mode 100644 index 00000000000..a293a8de231 --- /dev/null +++ b/app/views/projects/blob/viewers/_empty.html.haml @@ -0,0 +1,3 @@ +.file-content.code + .nothing-here-block + Empty file diff --git a/app/views/projects/blob/viewers/_image.html.haml b/app/views/projects/blob/viewers/_image.html.haml new file mode 100644 index 00000000000..640d59b3174 --- /dev/null +++ b/app/views/projects/blob/viewers/_image.html.haml @@ -0,0 +1,2 @@ +.file-content.image_file + %img{ src: blob_raw_url, alt: viewer.blob.name } diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml new file mode 100644 index 00000000000..b9a998d96ff --- /dev/null +++ b/app/views/projects/blob/viewers/_markup.html.haml @@ -0,0 +1,3 @@ +- blob = viewer.blob +.file-content.wiki + = markup(blob.name, blob.data) diff --git a/app/views/projects/blob/_notebook.html.haml b/app/views/projects/blob/viewers/_notebook.html.haml index ab1cf933944..2399fb16265 100644 --- a/app/views/projects/blob/_notebook.html.haml +++ b/app/views/projects/blob/viewers/_notebook.html.haml @@ -2,4 +2,4 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('notebook_viewer') -.file-content#js-notebook-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } +.file-content#js-notebook-viewer{ data: { endpoint: blob_raw_url } } diff --git a/app/views/projects/blob/_pdf.html.haml b/app/views/projects/blob/viewers/_pdf.html.haml index 58dc88e3bf7..1dd179c4fdc 100644 --- a/app/views/projects/blob/_pdf.html.haml +++ b/app/views/projects/blob/viewers/_pdf.html.haml @@ -2,4 +2,4 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('pdf_viewer') -.file-content#js-pdf-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } +.file-content#js-pdf-viewer{ data: { endpoint: blob_raw_url } } diff --git a/app/views/projects/blob/_sketch.html.haml b/app/views/projects/blob/viewers/_sketch.html.haml index dad9369cb2a..49f716c2c59 100644 --- a/app/views/projects/blob/_sketch.html.haml +++ b/app/views/projects/blob/viewers/_sketch.html.haml @@ -2,6 +2,6 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('sketch_viewer') -.file-content#js-sketch-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } +.file-content#js-sketch-viewer{ data: { endpoint: blob_raw_url } } .js-loading-icon.text-center.prepend-top-default.append-bottom-default.js-loading-icon{ 'aria-label' => 'Loading Sketch preview' } = icon('spinner spin 2x', 'aria-hidden' => 'true'); diff --git a/app/views/projects/blob/_stl.html.haml b/app/views/projects/blob/viewers/_stl.html.haml index a9332a0eeb6..e4e9d746176 100644 --- a/app/views/projects/blob/_stl.html.haml +++ b/app/views/projects/blob/viewers/_stl.html.haml @@ -2,7 +2,7 @@ = page_specific_javascript_bundle_tag('stl_viewer') .file-content.is-stl-loading - .text-center#js-stl-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } + .text-center#js-stl-viewer{ data: { endpoint: blob_raw_url } } = icon('spinner spin 2x', class: 'prepend-top-default append-bottom-default', 'aria-hidden' => 'true', 'aria-label' => 'Loading') .text-center.prepend-top-default.append-bottom-default.stl-controls .btn-group diff --git a/app/views/projects/blob/viewers/_svg.html.haml b/app/views/projects/blob/viewers/_svg.html.haml new file mode 100644 index 00000000000..62f647581b6 --- /dev/null +++ b/app/views/projects/blob/viewers/_svg.html.haml @@ -0,0 +1,4 @@ +- blob = viewer.blob +- data = sanitize_svg_data(blob.data) +.file-content.image_file + %img{ src: "data:#{blob.mime_type};base64,#{Base64.encode64(data)}", alt: blob.name } diff --git a/app/views/projects/blob/viewers/_text.html.haml b/app/views/projects/blob/viewers/_text.html.haml new file mode 100644 index 00000000000..a91df321ca0 --- /dev/null +++ b/app/views/projects/blob/viewers/_text.html.haml @@ -0,0 +1 @@ += render 'shared/file_highlight', blob: viewer.blob, repository: @repository diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml index 438a98c3e95..c781e423c4d 100644 --- a/app/views/projects/diffs/_content.html.haml +++ b/app/views/projects/diffs/_content.html.haml @@ -3,9 +3,9 @@ - return unless blob.respond_to?(:text?) - if diff_file.too_large? .nothing-here-block This diff could not be displayed because it is too large. - - elsif blob.only_display_raw? + - elsif blob.too_large? .nothing-here-block The file could not be displayed because it is too large. - - elsif blob_text_viewable?(blob) + - elsif blob.readable_text? - if !project.repository.diffable?(blob) .nothing-here-block This diff was suppressed by a .gitattributes entry. - elsif diff_file.collapsed? diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 4b49bed835f..71a1b9e6c05 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -27,7 +27,7 @@ - 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.only_display_raw? + - 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 --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 4622b980754..f22b385fc0f 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -6,7 +6,7 @@ - unless diff_file.submodule? .file-actions.hidden-xs - - if blob_text_viewable?(blob) + - 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 = icon('comment') \ diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 71ed23476d2..cf0540afb38 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -54,5 +54,5 @@ = number_with_delimiter(project.star_count) %span.prepend-left-10.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(project) } = visibility_level_icon(project.visibility_level, fw: true) - .prepend-top-5 + .prepend-top-0 updated #{updated_tooltip} diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml index 895c3f1e99d..37c66ff2595 100644 --- a/app/views/shared/snippets/_blob.html.haml +++ b/app/views/shared/snippets/_blob.html.haml @@ -9,7 +9,7 @@ .file-actions.hidden-xs .btn-group{ role: "group" }< - = copy_blob_content_button(@snippet) + = copy_blob_source_button(@snippet) = open_raw_file_button(raw_path) - if defined?(download_path) && download_path diff --git a/changelogs/unreleased/28457-slash-command-board-move.yml b/changelogs/unreleased/28457-slash-command-board-move.yml new file mode 100644 index 00000000000..cec0f89ed91 --- /dev/null +++ b/changelogs/unreleased/28457-slash-command-board-move.yml @@ -0,0 +1,4 @@ +--- +title: Add board_move slash command +merge_request: 10433 +author: Alex Sanford diff --git a/changelogs/unreleased/29181-add-more-tests-for-spec-controllers-projects-builds-controller-spec-rb.yml b/changelogs/unreleased/29181-add-more-tests-for-spec-controllers-projects-builds-controller-spec-rb.yml new file mode 100644 index 00000000000..7a3d687d73f --- /dev/null +++ b/changelogs/unreleased/29181-add-more-tests-for-spec-controllers-projects-builds-controller-spec-rb.yml @@ -0,0 +1,4 @@ +--- +title: Resolve "Add more tests for spec/controllers/projects/builds_controller_spec.rb" +merge_request: 10244 +author: dosuken123 diff --git a/changelogs/unreleased/add_index_on_ci_runners_contacted_at.yml b/changelogs/unreleased/add_index_on_ci_runners_contacted_at.yml new file mode 100644 index 00000000000..10c3206c2ff --- /dev/null +++ b/changelogs/unreleased/add_index_on_ci_runners_contacted_at.yml @@ -0,0 +1,4 @@ +--- +title: Add index on ci_runners.contacted_at +merge_request: 10876 +author: blackst0ne diff --git a/changelogs/unreleased/dm-blob-viewers.yml b/changelogs/unreleased/dm-blob-viewers.yml new file mode 100644 index 00000000000..5e0d41f3f29 --- /dev/null +++ b/changelogs/unreleased/dm-blob-viewers.yml @@ -0,0 +1,5 @@ +--- +title: Add Source/Rendered switch to blobs for SVG, Markdown, Asciidoc and other text + files that can be rendered +merge_request: +author: diff --git a/changelogs/unreleased/dm-fix-ghost-user-validation.yml b/changelogs/unreleased/dm-fix-ghost-user-validation.yml new file mode 100644 index 00000000000..4214786cb5a --- /dev/null +++ b/changelogs/unreleased/dm-fix-ghost-user-validation.yml @@ -0,0 +1,4 @@ +--- +title: Skip validation when creating internal (ghost, service desk) users +merge_request: +author: diff --git a/changelogs/unreleased/fix_build_header_line_height.yml b/changelogs/unreleased/fix_build_header_line_height.yml new file mode 100644 index 00000000000..95b6221f8d2 --- /dev/null +++ b/changelogs/unreleased/fix_build_header_line_height.yml @@ -0,0 +1,4 @@ +--- +title: Change line-height on build-header so elements don't overlap +merge_request: +author: Dino Maric diff --git a/changelogs/unreleased/make_markdown_tables_thinner.yml b/changelogs/unreleased/make_markdown_tables_thinner.yml new file mode 100644 index 00000000000..d03a26bdeb3 --- /dev/null +++ b/changelogs/unreleased/make_markdown_tables_thinner.yml @@ -0,0 +1,4 @@ +--- +title: Make markdown tables thinner +merge_request: 10909 +author: blackst0ne diff --git a/changelogs/unreleased/related-branch-ci-status-icon-alignment.yml b/changelogs/unreleased/related-branch-ci-status-icon-alignment.yml new file mode 100644 index 00000000000..198b6ce15ae --- /dev/null +++ b/changelogs/unreleased/related-branch-ci-status-icon-alignment.yml @@ -0,0 +1,4 @@ +--- +title: Fixed alignment of CI icon in issues related branches +merge_request: +author: diff --git a/config/routes.rb b/config/routes.rb index 1da226a3b57..2584981bb04 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -99,5 +99,7 @@ Rails.application.routes.draw do end end + draw :test if Rails.env.test? + get '*unmatched_route', to: 'application#route_not_found' end diff --git a/config/routes/project.rb b/config/routes/project.rb index fa92202c1ea..115ae2324b3 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -173,7 +173,7 @@ constraints(ProjectUrlConstrainer.new) do post :retry post :play post :erase - get :trace + get :trace, defaults: { format: 'json' } get :raw end diff --git a/config/routes/test.rb b/config/routes/test.rb new file mode 100644 index 00000000000..ac477cdbbbc --- /dev/null +++ b/config/routes/test.rb @@ -0,0 +1,2 @@ +get '/unicorn_test/pid' => 'unicorn_test#pid' +post '/unicorn_test/kill' => 'unicorn_test#kill' diff --git a/db/migrate/20170426181740_add_index_on_ci_runners_contacted_at.rb b/db/migrate/20170426181740_add_index_on_ci_runners_contacted_at.rb new file mode 100644 index 00000000000..879825a1934 --- /dev/null +++ b/db/migrate/20170426181740_add_index_on_ci_runners_contacted_at.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 AddIndexOnCiRunnersContactedAt < 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 :ci_runners, :contacted_at + end + + def down + remove_concurrent_index :ci_runners, :contacted_at if index_exists?(:ci_runners, :contacted_at) + end +end diff --git a/db/schema.rb b/db/schema.rb index 49d7c996661..b938657a186 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170426175636) do +ActiveRecord::Schema.define(version: 20170426181740) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -296,6 +296,7 @@ ActiveRecord::Schema.define(version: 20170426175636) do t.boolean "locked", default: false, null: false end + add_index "ci_runners", ["contacted_at"], name: "index_ci_runners_on_contacted_at", using: :btree add_index "ci_runners", ["is_shared"], name: "index_ci_runners_on_is_shared", using: :btree add_index "ci_runners", ["locked"], name: "index_ci_runners_on_locked", using: :btree add_index "ci_runners", ["token"], name: "index_ci_runners_on_token", using: :btree diff --git a/doc/development/writing_documentation.md b/doc/development/writing_documentation.md index 166a10293c3..2814c18e0b6 100644 --- a/doc/development/writing_documentation.md +++ b/doc/development/writing_documentation.md @@ -70,3 +70,27 @@ All the docs follow the same [styleguide](doc_styleguide.md). ### Markdown Currently GitLab docs use Redcarpet as [markdown](../user/markdown.md) engine, but there's an [open discussion](https://gitlab.com/gitlab-com/gitlab-docs/issues/50) for implementing Kramdown in the near future. + +## Testing + +We try to treat documentation as code, thus have implemented some testing. +Currently, the following tests are in place: + +1. `docs:check:links`: Check that all internal (relative) links work correctly +1. `docs:check:apilint`: Check that the API docs follow some conventions + +If your contribution contains **only** documentation changes, you can speed up +the CI process by prepending to the name of your branch: `docs/`. For example, +a valid name would be `docs/update-api-issues` and it will run only the docs +tests. If the name is `docs-update-api-issues`, the whole test suite will run +(including docs). + +--- + +When you submit a merge request to GitLab Community Edition (CE), there is an +additional job called `rake ee_compat_check` that runs against Enterprise +Edition (EE) and checks if your changes can apply cleanly to the EE codebase. +If that job fails, read the instructions in the job log for what to do next. +Contributors do not need to submit their changes to EE, GitLab Inc. employees +on the other hand need to make sure that their changes apply cleanly to both +CE and EE. diff --git a/doc/integration/chat_commands.md b/doc/integration/chat_commands.md index 4b0084678d9..c878dc7e650 100644 --- a/doc/integration/chat_commands.md +++ b/doc/integration/chat_commands.md @@ -1,14 +1,14 @@ # Chat Commands -Chat commands allow user to perform common operations on GitLab right from there chat client. -Right now both Mattermost and Slack are supported. +Chat commands in Mattermost and Slack (also called Slack slash commands) allow you to control GitLab and view GitLab content right inside your chat client, without having to leave it. For Slack, this requires a [project service configuration](../user/project/integrations/slack_slash_commands.md). Simply type the command as a message in your chat client to activate it. -## Available commands +Commands are scoped to a project, with a trigger term that is specified during configuration. (We suggest you use the project name as the trigger term for simplicty and clarity.) Taking the trigger term as `project-name`, the commands are: -The trigger is configurable, but for the sake of this example, we'll use `/trigger` -* `/trigger help` - Displays all available commands for this user -* `/trigger issue new <title> <shift+return> <description>` - creates a new issue on the project -* `/trigger issue show <id>` - Shows the issue with the given ID, if you've got access -* `/trigger issue search <query>` - Shows a maximum of 5 items matching the query -* `/trigger deploy <from> to <to>` - Deploy from an environment to another +| Command | Effect | +| ------- | ------ | +| `/project-name help` | Shows all available chat commands | +| `/project-name issue new <title> <shift+return> <description>` | Creates a new issue with title `<title>` and description `<description>` | +| `/project-name issue show <id>` | Shows the issue with id `<id>` | +| `/project-name issue search <query>` | Shows up to 5 issues matching `<query>` | +| `/project-name deploy <from> to <to>` | Deploy from the `<from>` environment to the `<to>` environment |
\ No newline at end of file diff --git a/doc/user/project/integrations/project_services.md b/doc/user/project/integrations/project_services.md index 96c91093d7d..31baea507d7 100644 --- a/doc/user/project/integrations/project_services.md +++ b/doc/user/project/integrations/project_services.md @@ -49,8 +49,8 @@ Click on the service links to see further configuration instructions and details | [Mattermost Notifications](mattermost.md) | Receive event notifications in Mattermost | | [Microsoft teams](microsoft_teams.md) | Receive notifications for actions that happen on GitLab into a room on Microsoft Teams using Office 365 Connectors | | Pipelines emails | Email the pipeline status to a list of recipients | -| [Slack Notifications](slack.md) | Receive event notifications in Slack | -| [Slack slash commands](slack_slash_commands.md) | Slack chat and ChatOps slash commands | +| [Slack Notifications](slack.md) | Send GitLab events (e.g. issue created) to Slack as notifications | +| [Slack slash commands](slack_slash_commands.md) | Use slash commands in Slack to control GitLab | | PivotalTracker | Project Management Software (Source Commits Endpoint) | | [Prometheus](prometheus.md) | Monitor the performance of your deployed apps | | Pushover | Pushover makes it easy to get real-time notifications on your Android device, iPhone, iPad, and Desktop | diff --git a/doc/user/project/integrations/slack.md b/doc/user/project/integrations/slack.md index e8b238351ca..af4ca35a215 100644 --- a/doc/user/project/integrations/slack.md +++ b/doc/user/project/integrations/slack.md @@ -1,51 +1,26 @@ # Slack Notifications Service -## On Slack +The Slack Notifications Service allows your GitLab project to send events (e.g. issue created) to your existing Slack team as notifications. This requires configurations in both Slack and GitLab. -To enable Slack integration you must create an incoming webhook integration on -Slack: +> Note: You can also use Slack slash commands to control GitLab inside Slack. This is the separately configured [Slack slash commands](slack_slash_commands.md). -1. [Sign in to Slack](https://slack.com/signin) -1. Visit [Incoming WebHooks](https://my.slack.com/services/new/incoming-webhook/) -1. Choose the channel name you want to send notifications to. -1. Click **Add Incoming WebHooks Integration** -1. Copy the **Webhook URL**, we'll need this later for GitLab. +## Slack Configuration -## On GitLab +1. Sign in to your Slack team and [start a new Incoming WebHooks configuration](https://my.slack.com/services/new/incoming-webhook/). +1. Select the Slack channel where notifications will be sent to by default. Click the **Add Incoming WebHooks integration** button to add the configuration. +1. Copy the **Webhook URL**, which we'll use later in the GitLab configuration. -After you set up Slack, it's time to set up GitLab. +## GitLab Configuration -Navigate to the [Integrations page](project_services.md#accessing-the-project-services) -and select the **Slack notifications** service to configure it. -There, you will see a checkbox with the following events that can be triggered: +1. Navigate to the [Integrations page](project_services.md#accessing-the-project-services) in your project's settings, i.e. **Project > Settings > Integrations**. +1. Select the **Slack notifications** project service to configure it. +1. Check the **Active** checkbox to turn on the service. +1. Check the checkboxes corresponding to the GitLab events you want to send to Slack as a notification. +1. For each event, optionally enter the Slack channel where you want to send the event. (Do _not_ include the `#` symbol.) If left empty, the event will be sent to the default channel that you configured in the Slack Configuration step. +1. Paste the **Webhook URL** that you copied from the Slack Configuration step. +1. Optionally customize the Slack bot username that will be sending the notifications. +1. Configure the remaining options and click `Save changes`. -- Push -- Issue -- Confidential issue -- Merge request -- Note -- Tag push -- Pipeline -- Wiki page +Your Slack team will now start receiving GitLab event notifications as configured. -Below each of these event checkboxes, you have an input field to enter -which Slack channel you want to send that event message. Enter your preferred channel name **without** the hash sign (`#`). - -At the end, fill in your Slack details: - -| Field | Description | -| ----- | ----------- | -| **Webhook** | The [incoming webhook URL][slackhook] which you have to setup on Slack. | -| **Username** | Optional username which can be on messages sent to Slack. Fill this in if you want to change the username of the bot. | -| **Notify only broken pipelines** | If you choose to enable the **Pipeline** event and you want to be only notified about failed pipelines. | - -After you are all done, click **Save changes** for the changes to take effect. - ->**Note:** -You can set "branch,pushed,Compare changes" as highlight words on your Slack -profile settings, so that you can be aware of new commits when somebody pushes -them. - -![Slack configuration](img/slack_configuration.png) - -[slackhook]: https://my.slack.com/services/new/incoming-webhook +![Slack configuration](img/slack_configuration.png)
\ No newline at end of file diff --git a/doc/user/project/integrations/slack_slash_commands.md b/doc/user/project/integrations/slack_slash_commands.md index 56f1ba7311e..54e0ee611cb 100644 --- a/doc/user/project/integrations/slack_slash_commands.md +++ b/doc/user/project/integrations/slack_slash_commands.md @@ -2,23 +2,22 @@ > Introduced in GitLab 8.15 -Slack commands give users an extra interface to perform common operations -from the chat environment. This allows one to, for example, create an issue as -soon as the idea was discussed in chat. -For all available commands try the help subcommand, for example: `/gitlab help`, -all review the [full list of commands](../../../integration/chat_commands.md). +Slack slash commands (also known as chat commmands) allow you to control GitLab and view content right inside Slack, without having to leave it. This requires configurations in both Slack and GitLab. -## Prerequisites - -A [team](https://get.slack.help/hc/en-us/articles/217608418-Creating-a-team) in -Slack should be created beforehand, GitLab cannot create it for you. +> Note: GitLab can also send events (e.g. issue created) to Slack as notifications. This is the separately configured [Slack Notifications Service](slack.md). ## Configuration -Go to your project's [Integrations page](project_services.md#accessing-the-project-services) -and select the **Slack slash commands** service to configure it. +1. Slack slash commands are scoped to a project. Navigate to the [Integrations page](project_services.md#accessing-the-project-services) in your project's settings, i.e. **Project > Settings > Integrations**. +1. Select the **Slack slash commands** project service to configure it. This page contains required information to complete the configuration in Slack. Leave this browser tab open. +1. Open a new browser tab and sign in to your Slack team. [Start a new Slash Commands integration](https://my.slack.com/services/new/slash-commands). +1. Enter a trigger term. We suggest you use the project name. Click **Add Slash Command Integration**. +1. Complete the rest of the fields in the Slack configuration page using information from the GitLab browser tab. In particular, the URL needs to be copied and pasted. Click **Save Integration** to complete the configuration in Slack. +1. While still on the Slack configuration page, copy the **token**. Go back to the GitLab browser tab and paste in the **token**. +1. Check the **Active** checkbox and click **Save changes** to complete the configuration in GitLab. ![Slack setup instructions](img/slack_setup.png) -Once you've followed the instructions, mark the service as active and insert the token -you've received from Slack. After saving the service you are good to go! +## Usage + +You can now use the [Slack slash commands](../../../integration/chat_commands.md).
\ No newline at end of file diff --git a/doc/user/project/slash_commands.md b/doc/user/project/slash_commands.md index 45176fde9db..08452ca75cd 100644 --- a/doc/user/project/slash_commands.md +++ b/doc/user/project/slash_commands.md @@ -36,3 +36,4 @@ do. | `/remove_time_spent` | Remove time spent | | `/target_branch <Branch Name>` | Set target branch for current merge request | | `/award :emoji:` | Toggle award for :emoji: | +| `/board_move ~column` | Move issue to column on the board | diff --git a/features/project/source/browse_files.feature b/features/project/source/browse_files.feature index d81bc9802bc..472ec9544f3 100644 --- a/features/project/source/browse_files.feature +++ b/features/project/source/browse_files.feature @@ -10,7 +10,8 @@ Feature: Project Source Browse Files Scenario: I browse files for specific ref Given I visit project source page for "6d39438" Then I should see files from repository for "6d39438" - + + @javascript Scenario: I browse file content Given I click on ".gitignore" file in repo Then I should see its content diff --git a/features/project/source/markdown_render.feature b/features/project/source/markdown_render.feature index ecbd721c281..fd583618dcf 100644 --- a/features/project/source/markdown_render.feature +++ b/features/project/source/markdown_render.feature @@ -6,11 +6,13 @@ Feature: Project Source Markdown Render # Tree README + @javascript Scenario: Tree view should have correct links in README Given I go directory which contains README file And I click on a relative link in README Then I should see the correct markdown + @javascript Scenario: I browse files from markdown branch Then I should see files from repository in markdown And I should see rendered README which contains correct links @@ -29,36 +31,42 @@ Feature: Project Source Markdown Render And I click on GitLab API doc directory in README Then I should see correct doc/api directory rendered + @javascript Scenario: I view README in markdown branch to see reference links to file Then I should see files from repository in markdown And I should see rendered README which contains correct links And I click on Maintenance in README Then I should see correct maintenance file rendered + @javascript Scenario: README headers should have header links Then I should see rendered README which contains correct links And Header "Application details" should have correct id and link # Blob + @javascript Scenario: I navigate to doc directory to view documentation in markdown And I navigate to the doc/api/README And I see correct file rendered And I click on users in doc/api/README Then I should see the correct document file + @javascript Scenario: I navigate to doc directory to view user doc in markdown And I navigate to the doc/api/README And I see correct file rendered And I click on raketasks in doc/api/README Then I should see correct directory rendered + @javascript Scenario: I navigate to doc directory to view user doc in markdown And I navigate to the doc/api/README And Header "GitLab API" should have correct id and link # Markdown branch + @javascript Scenario: I browse files from markdown branch When I visit markdown branch Then I should see files from repository in markdown branch @@ -73,6 +81,7 @@ Feature: Project Source Markdown Render And I click on Rake tasks in README Then I should see correct directory rendered for markdown branch + @javascript Scenario: I navigate to doc directory to view documentation in markdown branch When I visit markdown branch And I navigate to the doc/api/README @@ -80,6 +89,7 @@ Feature: Project Source Markdown Render And I click on users in doc/api/README Then I should see the users document file in markdown branch + @javascript Scenario: I navigate to doc directory to view user doc in markdown branch When I visit markdown branch And I navigate to the doc/api/README @@ -87,6 +97,7 @@ Feature: Project Source Markdown Render And I click on raketasks in doc/api/README Then I should see correct directory rendered for markdown branch + @javascript Scenario: Tree markdown links view empty urls should have correct urls When I visit markdown branch Then The link with text "empty" should have url "tree/markdown" @@ -99,6 +110,7 @@ Feature: Project Source Markdown Render # "ID" means "#id" on the tests below, because we are unable to escape the hash sign. # which Spinach interprets as the start of a comment. + @javascript Scenario: All markdown links with ids should have correct urls When I visit markdown branch Then The link with text "ID" should have url "tree/markdownID" diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index b4741f06d1b..36fe21a047c 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -4,6 +4,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps include SharedProject include SharedPaths include RepoHelpers + include WaitForAjax step "I don't have write access" do @project = create(:project, :repository, name: "Other Project", path: "other-project") @@ -36,10 +37,12 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps end step 'I should see its content' do + wait_for_ajax expect(page).to have_content old_gitignore_content end step 'I should see its new content' do + wait_for_ajax expect(page).to have_content new_gitignore_content end diff --git a/features/steps/project/source/markdown_render.rb b/features/steps/project/source/markdown_render.rb index 0f0827f0477..abdbd795cd5 100644 --- a/features/steps/project/source/markdown_render.rb +++ b/features/steps/project/source/markdown_render.rb @@ -5,6 +5,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps include SharedAuthentication include SharedPaths include SharedMarkdown + include WaitForAjax step 'I own project "Delta"' do @project = ::Project.find_by(name: "Delta") @@ -34,6 +35,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I should see correct document rendered' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md") + wait_for_ajax expect(page).to have_content "All API requests require authentication" end @@ -63,6 +65,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I should see correct maintenance file rendered' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/raketasks/maintenance.md") + wait_for_ajax expect(page).to have_content "bundle exec rake gitlab:env:info RAILS_ENV=production" end @@ -94,6 +97,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I see correct file rendered' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md") + wait_for_ajax expect(page).to have_content "Contents" expect(page).to have_link "Users" expect(page).to have_link "Rake tasks" @@ -138,6 +142,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I see correct file rendered in markdown branch' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md") + wait_for_ajax expect(page).to have_content "Contents" expect(page).to have_link "Users" expect(page).to have_link "Rake tasks" @@ -145,6 +150,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I should see correct document rendered for markdown branch' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md") + wait_for_ajax expect(page).to have_content "All API requests require authentication" end @@ -162,6 +168,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps # Expected link contents step 'The link with text "empty" should have url "tree/markdown"' do + wait_for_ajax find('a', text: /^empty$/)['href'] == current_host + namespace_project_tree_path(@project.namespace, @project, "markdown") end @@ -197,6 +204,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps end step 'The link with text "ID" should have url "blob/markdown/README.mdID"' do + wait_for_ajax find('a', text: /^#id$/)['href'] == current_host + namespace_project_blob_path(@project.namespace, @project, "markdown/README.md") + '#id' end @@ -291,10 +299,12 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I should see the correct markdown' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/users.md") + wait_for_ajax expect(page).to have_content "List users" end step 'Header "Application details" should have correct id and link' do + wait_for_ajax header_should_have_correct_id_and_link(2, 'Application details', 'application-details') end diff --git a/features/steps/shared/markdown.rb b/features/steps/shared/markdown.rb index 875d27d9383..6610b97ecb2 100644 --- a/features/steps/shared/markdown.rb +++ b/features/steps/shared/markdown.rb @@ -3,7 +3,7 @@ module SharedMarkdown def header_should_have_correct_id_and_link(level, text, id, parent = ".wiki") node = find("#{parent} h#{level} a#user-content-#{id}") - expect(node[:href]).to eq "##{id}" + expect(node[:href]).to end_with "##{id}" # Work around a weird Capybara behavior where calling `parent` on a node # returns the whole document, not the node's actual parent element diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb index 98fd4e78126..e8bb9e1f805 100644 --- a/lib/gitlab/git/blob.rb +++ b/lib/gitlab/git/blob.rb @@ -109,10 +109,6 @@ module Gitlab @binary.nil? ? super : @binary == true end - def empty? - !data || data == '' - end - def data encode! @data end diff --git a/lib/tasks/brakeman.rake b/lib/tasks/brakeman.rake index 2301ec9b228..99b3168d9eb 100644 --- a/lib/tasks/brakeman.rake +++ b/lib/tasks/brakeman.rake @@ -2,7 +2,7 @@ desc 'Security check via brakeman' task :brakeman do # We get 0 warnings at level 'w3' but we would like to reach 'w2'. Merge # requests are welcome! - if system(*%w(brakeman --no-progress --skip-files lib/backup/repository.rb -w3 -z)) + if system(*%w(brakeman --no-progress --skip-files lib/backup/repository.rb,app/controllers/unicorn_test_controller.rb -w3 -z)) puts 'Security check succeed' else puts 'Security check failed' diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh index de7379425cf..fd173c0ba88 100755 --- a/scripts/prepare_build.sh +++ b/scripts/prepare_build.sh @@ -32,7 +32,7 @@ sed -i 's/localhost/redis/g' config/resque.yml cp config/gitlab.yml.example config/gitlab.yml if [ "$USE_BUNDLE_INSTALL" != "false" ]; then - retry bundle install --clean $BUNDLE_INSTALL_FLAGS + retry bundle install --clean $BUNDLE_INSTALL_FLAGS && bundle check fi # Only install knapsack after bundle install! Otherwise oddly some native diff --git a/spec/controllers/projects/builds_controller_spec.rb b/spec/controllers/projects/builds_controller_spec.rb index fb4ccfa58c2..22193eac672 100644 --- a/spec/controllers/projects/builds_controller_spec.rb +++ b/spec/controllers/projects/builds_controller_spec.rb @@ -1,14 +1,69 @@ require 'spec_helper' describe Projects::BuildsController do - let(:user) { create(:user) } - let(:project) { create(:empty_project, :public) } + include ApiHelpers - before do - sign_in(user) - end + let(:project) { create(:empty_project, :public) } + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:user) { create(:user) } describe 'GET index' do + context 'when scope is pending' do + before do + create(:ci_build, :pending, pipeline: pipeline) + + get_index(scope: 'pending') + end + + it 'has only pending builds' do + expect(response).to have_http_status(:ok) + expect(assigns(:builds).first.status).to eq('pending') + end + end + + context 'when scope is running' do + before do + create(:ci_build, :running, pipeline: pipeline) + + get_index(scope: 'running') + end + + it 'has only running builds' do + expect(response).to have_http_status(:ok) + expect(assigns(:builds).first.status).to eq('running') + end + end + + context 'when scope is finished' do + before do + create(:ci_build, :success, pipeline: pipeline) + + get_index(scope: 'finished') + end + + it 'has only finished builds' do + expect(response).to have_http_status(:ok) + expect(assigns(:builds).first.status).to eq('success') + end + end + + context 'when page is specified' do + let(:last_page) { project.builds.page.total_pages } + + context 'when page number is eligible' do + before do + create_list(:ci_build, 2, pipeline: pipeline) + + get_index(page: last_page.to_param) + end + + it 'redirects to the page' do + expect(response).to have_http_status(:ok) + expect(assigns(:builds).current_page).to eq(last_page) + end + end + end + context 'number of queries' do before do Ci::Build::AVAILABLE_STATUSES.each do |status| @@ -23,13 +78,8 @@ describe Projects::BuildsController do RequestStore.clear! end - def render - get :index, namespace_id: project.namespace, - project_id: project - end - it "verifies number of queries" do - recorded = ActiveRecord::QueryRecorder.new { render } + recorded = ActiveRecord::QueryRecorder.new { get_index } expect(recorded.count).to be_within(5).of(8) end @@ -39,10 +89,83 @@ describe Projects::BuildsController do pipeline: pipeline, name: name, status: status) end end + + def get_index(**extra_params) + params = { + namespace_id: project.namespace.to_param, + project_id: project + } + + get :index, params.merge(extra_params) + end + end + + describe 'GET show' do + context 'when build exists' do + let!(:build) { create(:ci_build, pipeline: pipeline) } + + 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 + + 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 + + def get_show(**extra_params) + params = { + namespace_id: project.namespace.to_param, + project_id: project + } + + get :show, params.merge(extra_params) + end + end + + describe 'GET trace.json' do + before do + get_trace + end + + context 'when build has a trace' do + let(:build) { create(:ci_build, :trace, pipeline: pipeline) } + + it 'returns a trace' do + expect(response).to have_http_status(:ok) + expect(json_response['html']).to eq('BUILD TRACE') + end + end + + context 'when build has no traces' do + let(:build) { create(:ci_build, pipeline: pipeline) } + + it 'returns no traces' do + expect(response).to have_http_status(:ok) + expect(json_response['html']).to be_nil + end + end + + def get_trace + get :trace, namespace_id: project.namespace, + project_id: project, + id: build.id, + format: :json + end end describe 'GET status.json' do - let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, pipeline: pipeline) } let(:status) { build.detailed_status(double('user')) } @@ -71,6 +194,7 @@ describe Projects::BuildsController do before do project.add_developer(user) sign_in(user) + get_trace end @@ -84,6 +208,7 @@ describe Projects::BuildsController do context 'when user is logged in as non member' do before do sign_in(user) + get_trace end @@ -101,4 +226,221 @@ describe Projects::BuildsController do format: :json end end + + describe 'POST retry' do + before do + project.add_developer(user) + sign_in(user) + + post_retry + end + + context 'when build is retryable' do + let(:build) { create(:ci_build, :retryable, pipeline: pipeline) } + + 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)) + end + end + + context 'when build is not retryable' do + let(:build) { create(:ci_build, pipeline: pipeline) } + + it 'renders unprocessable_entity' do + expect(response).to have_http_status(:unprocessable_entity) + end + end + + def post_retry + post :retry, namespace_id: project.namespace, + project_id: project, + id: build.id + end + end + + describe 'POST play' do + before do + project.add_developer(user) + sign_in(user) + + post_play + end + + context 'when build is playable' do + let(:build) { create(:ci_build, :playable, pipeline: pipeline) } + + 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)) + end + + it 'transits to pending' do + expect(build.reload).to be_pending + end + end + + context 'when build is not playable' do + let(:build) { create(:ci_build, pipeline: pipeline) } + + it 'renders unprocessable_entity' do + expect(response).to have_http_status(:unprocessable_entity) + end + end + + def post_play + post :play, namespace_id: project.namespace, + project_id: project, + id: build.id + end + end + + describe 'POST cancel' do + before do + project.add_developer(user) + sign_in(user) + + post_cancel + end + + context 'when build is cancelable' do + let(:build) { create(:ci_build, :cancelable, pipeline: pipeline) } + + 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)) + end + + it 'transits to canceled' do + expect(build.reload).to be_canceled + end + end + + context 'when build is not cancelable' do + let(:build) { create(:ci_build, :canceled, pipeline: pipeline) } + + it 'returns unprocessable_entity' do + expect(response).to have_http_status(:unprocessable_entity) + end + end + + def post_cancel + post :cancel, namespace_id: project.namespace, + project_id: project, + id: build.id + end + end + + describe 'POST cancel_all' do + before do + project.add_developer(user) + sign_in(user) + end + + context 'when builds are cancelable' do + before do + create_list(:ci_build, 2, :cancelable, pipeline: pipeline) + + post_cancel_all + end + + it 'redirects to a index page' do + expect(response).to have_http_status(:found) + expect(response).to redirect_to(namespace_project_builds_path) + end + + it 'transits to canceled' do + expect(Ci::Build.all).to all(be_canceled) + end + end + + context 'when builds are not cancelable' do + before do + create_list(:ci_build, 2, :canceled, pipeline: pipeline) + + post_cancel_all + end + + it 'redirects to a index page' do + expect(response).to have_http_status(:found) + expect(response).to redirect_to(namespace_project_builds_path) + end + end + + def post_cancel_all + post :cancel_all, namespace_id: project.namespace, + project_id: project + end + end + + describe 'POST erase' do + before do + project.add_developer(user) + sign_in(user) + + post_erase + end + + context 'when build is erasable' do + let(:build) { create(:ci_build, :erasable, :trace, pipeline: pipeline) } + + 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)) + end + + it 'erases artifacts' do + expect(build.artifacts_file.exists?).to be_falsey + expect(build.artifacts_metadata.exists?).to be_falsey + end + + it 'erases trace' do + expect(build.trace.exist?).to be_falsey + end + end + + context 'when build is not erasable' do + let(:build) { create(:ci_build, :erased, pipeline: pipeline) } + + it 'returns unprocessable_entity' do + expect(response).to have_http_status(:unprocessable_entity) + end + end + + def post_erase + post :erase, namespace_id: project.namespace, + project_id: project, + id: build.id + end + end + + describe 'GET raw' do + before do + get_raw + end + + context 'when build has a trace file' do + let(:build) { create(:ci_build, :trace, pipeline: pipeline) } + + it 'send a trace file' do + expect(response).to have_http_status(:ok) + expect(response.content_type).to eq 'text/plain; charset=utf-8' + expect(response.body).to eq 'BUILD TRACE' + end + end + + context 'when build does not have a trace file' do + let(:build) { create(:ci_build, pipeline: pipeline) } + + it 'returns not_found' do + expect(response).to have_http_status(:not_found) + end + end + + def get_raw + post :raw, namespace_id: project.namespace, + project_id: project, + id: build.id + end + end end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index b62def83ee4..78ddd8d5584 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -79,6 +79,19 @@ FactoryGirl.define do manual end + trait :retryable do + success + end + + trait :cancelable do + pending + end + + trait :erasable do + success + artifacts + end + trait :tags do tag_list [:docker, :ruby] end diff --git a/spec/features/copy_as_gfm_spec.rb b/spec/features/copy_as_gfm_spec.rb index 344e31e5ef5..f197fb44608 100644 --- a/spec/features/copy_as_gfm_spec.rb +++ b/spec/features/copy_as_gfm_spec.rb @@ -479,6 +479,7 @@ describe 'Copy as GFM', feature: true, js: true do context 'from a blob' do before do visit namespace_project_blob_path(project.namespace, project, File.join('master', 'files/ruby/popen.rb')) + wait_for_ajax end context 'selecting one word of text' do @@ -520,6 +521,7 @@ describe 'Copy as GFM', feature: true, js: true do context 'from a GFM code block' do before do visit namespace_project_blob_path(project.namespace, project, File.join('markdown', 'doc/api/users.md')) + wait_for_ajax end context 'selecting one word of text' do diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb index 7cfa5b9716f..cc11cb7a55f 100644 --- a/spec/features/projects/blobs/blob_show_spec.rb +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -1,21 +1,313 @@ require 'spec_helper' -feature 'File blob', feature: true do +feature 'File blob', :js, feature: true do include TreeHelper + include WaitForAjax - let(:project) { create(:project, :public, :test_repo) } - let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master') } - let(:branch) { 'master' } - let(:file_path) { project.repository.ls_files(project.repository.root_ref)[1] } + let(:project) { create(:project, :public) } - context 'anonymous' do - context 'from blob file path' do + def visit_blob(path, fragment = nil) + visit namespace_project_blob_path(project.namespace, project, tree_join('master', path), anchor: fragment) + end + + context 'Ruby file' do + before do + visit_blob('files/ruby/popen.rb') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows highlighted Ruby code + expect(page).to have_content("require 'fileutils'") + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # shows an enabled copy button + expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + end + end + end + + context 'Markdown file' do + context 'visiting directly' do before do - visit namespace_project_blob_path(project.namespace, project, tree_join(branch, file_path)) + visit_blob('files/markdown/ruby-style-guide.md') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # hides the simple viewer + expect(page).to have_selector('.blob-viewer[data-type="simple"]', visible: false) + expect(page).to have_selector('.blob-viewer[data-type="rich"]') + + # shows rendered Markdown + expect(page).to have_link("PEP-8") + + # shows a viewer switcher + expect(page).to have_selector('.js-blob-viewer-switcher') + + # shows a disabled copy button + expect(page).to have_selector('.js-copy-blob-source-btn.disabled') + end + end + + context 'switching to the simple viewer' do + before do + find('.js-blob-viewer-switch-btn[data-viewer=simple]').click + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # hides the rich viewer + expect(page).to have_selector('.blob-viewer[data-type="simple"]') + expect(page).to have_selector('.blob-viewer[data-type="rich"]', visible: false) + + # shows highlighted Markdown code + expect(page).to have_content("[PEP-8](http://www.python.org/dev/peps/pep-0008/)") + + # shows an enabled copy button + expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + end + end + + context 'switching to the rich viewer again' do + before do + find('.js-blob-viewer-switch-btn[data-viewer=rich]').click + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # hides the simple viewer + expect(page).to have_selector('.blob-viewer[data-type="simple"]', visible: false) + expect(page).to have_selector('.blob-viewer[data-type="rich"]') + + # shows an enabled copy button + expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + end + end + end end + end + + context 'visiting with a line number anchor' do + before do + visit_blob('files/markdown/ruby-style-guide.md', 'L1') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # hides the rich viewer + expect(page).to have_selector('.blob-viewer[data-type="simple"]') + expect(page).to have_selector('.blob-viewer[data-type="rich"]', visible: false) + + # highlights the line in question + expect(page).to have_selector('#LC1.hll') + + # shows highlighted Markdown code + expect(page).to have_content("[PEP-8](http://www.python.org/dev/peps/pep-0008/)") + + # shows an enabled copy button + expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + end + end + end + end + + context 'Markdown file (stored in LFS)' do + before do + project.add_master(project.creator) + + Files::CreateService.new( + project, + project.creator, + start_branch: 'master', + branch_name: 'master', + commit_message: "Add Markdown in LFS", + file_path: 'files/lfs/file.md', + file_content: project.repository.blob_at('master', 'files/lfs/lfs_object.iso').data + ).execute + end + + context 'when LFS is enabled on the project' do + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + project.update_attribute(:lfs_enabled, true) + + visit_blob('files/lfs/file.md') + + wait_for_ajax + end + + it 'displays an error' do + aggregate_failures do + # hides the simple viewer + expect(page).to have_selector('.blob-viewer[data-type="simple"]', visible: false) + expect(page).to have_selector('.blob-viewer[data-type="rich"]') + + # shows an error message + expect(page).to have_content('The rendered file could not be displayed because it is stored in LFS. You can view the source or download it instead.') + + # shows a viewer switcher + expect(page).to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + end + end + + context 'switching to the simple viewer' do + before do + find('.js-blob-viewer-switcher .js-blob-viewer-switch-btn[data-viewer=simple]').click + + wait_for_ajax + end + + it 'displays an error' do + aggregate_failures do + # hides the rich viewer + expect(page).to have_selector('.blob-viewer[data-type="simple"]') + expect(page).to have_selector('.blob-viewer[data-type="rich"]', visible: false) + + # shows an error message + expect(page).to have_content('The source could not be displayed because it is stored in LFS. You can download it instead.') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + end + end + end + end + + context 'when LFS is disabled on the project' do + before do + visit_blob('files/lfs/file.md') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows text + expect(page).to have_content('size 1575078') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # shows an enabled copy button + expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + end + end + end + end + + context 'PDF file' do + before do + project.add_master(project.creator) + + Files::CreateService.new( + project, + project.creator, + start_branch: 'master', + branch_name: 'master', + commit_message: "Add PDF", + file_path: 'files/test.pdf', + file_content: File.read(Rails.root.join('spec/javascripts/blob/pdf/test.pdf')) + ).execute + + visit_blob('files/test.pdf') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows rendered PDF + expect(page).to have_selector('.js-pdf-viewer') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + end + end + end + + context 'ISO file (stored in LFS)' do + context 'when LFS is enabled on the project' do + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + project.update_attribute(:lfs_enabled, true) + + visit_blob('files/lfs/lfs_object.iso') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows a download link + expect(page).to have_link('Download (1.5 MB)') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + end + end + end + + context 'when LFS is disabled on the project' do + before do + visit_blob('files/lfs/lfs_object.iso') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows text + expect(page).to have_content('size 1575078') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # shows an enabled copy button + expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + end + end + end + end + + context 'ZIP file' do + before do + visit_blob('Gemfile.zip') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows a download link + expect(page).to have_link('Download (2.11 KB)') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') - it 'updates content' do - expect(page).to have_link 'Edit' + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') end end end diff --git a/spec/features/projects/files/browse_files_spec.rb b/spec/features/projects/files/browse_files_spec.rb index d281043caa3..70e96efd557 100644 --- a/spec/features/projects/files/browse_files_spec.rb +++ b/spec/features/projects/files/browse_files_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'user browses project', feature: true do +feature 'user browses project', feature: true, js: true do let(:project) { create(:project) } let(:user) { create(:user) } @@ -13,7 +13,7 @@ feature 'user browses project', feature: true do scenario "can see blame of '.gitignore'" do click_link ".gitignore" click_link 'Blame' - + expect(page).to have_content "*.rb" expect(page).to have_content "Dmitriy Zaporozhets" expect(page).to have_content "Initial commit" @@ -24,6 +24,7 @@ feature 'user browses project', feature: true do click_link 'files' click_link 'lfs' click_link 'lfs_object.iso' + wait_for_ajax expect(page).not_to have_content 'Download (1.5 MB)' expect(page).to have_content 'version https://git-lfs.github.com/spec/v1' diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 6ecdc8cbb71..a1a36931824 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -399,6 +399,44 @@ describe "Internal Project Access", feature: true do end end + 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) } + + context 'when allowed for public and internal' do + before do + project.update(public_builds: true) + end + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_allowed_for(:guest).of(project) } + it { is_expected.to be_allowed_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + + context 'when disallowed for public and internal' do + before do + project.update(public_builds: false) + end + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + end + describe "GET /:project_path/environments" do subject { namespace_project_environments_path(project.namespace, project) } diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index a8fc0624588..5d58494a22a 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -388,6 +388,38 @@ describe "Private Project Access", feature: true do end end + 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) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + + context 'when public builds is enabled' do + before do + project.update(public_builds: true) + end + + it { is_expected.to be_allowed_for(:guest).of(project) } + end + + context 'when public builds is disabled' do + before do + project.update(public_builds: false) + end + + it { is_expected.to be_denied_for(:guest).of(project) } + end + end + describe "GET /:project_path/environments" do subject { namespace_project_environments_path(project.namespace, project) } diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index c4d2f50ca14..5df5b710dc4 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -219,6 +219,44 @@ describe "Public Project Access", feature: true do end end + 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) } + + context 'when allowed for public' do + before do + project.update(public_builds: true) + end + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_allowed_for(:guest).of(project) } + it { is_expected.to be_allowed_for(:user) } + it { is_expected.to be_allowed_for(:external) } + it { is_expected.to be_allowed_for(:visitor) } + end + + context 'when disallowed for public' do + before do + project.update(public_builds: false) + end + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_allowed_for(:developer).of(project) } + it { is_expected.to be_allowed_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + end + describe "GET /:project_path/environments" do subject { namespace_project_environments_path(project.namespace, project) } diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index 508aeb7cf67..379f62f73e1 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -56,15 +56,14 @@ describe BlobHelper do end end - describe "#sanitize_svg" do + describe "#sanitize_svg_data" do let(:input_svg_path) { File.join(Rails.root, 'spec', 'fixtures', 'unsanitized.svg') } let(:data) { open(input_svg_path).read } let(:expected_svg_path) { File.join(Rails.root, 'spec', 'fixtures', 'sanitized.svg') } let(:expected) { open(expected_svg_path).read } it 'retains essential elements' do - blob = OpenStruct.new(data: data) - expect(sanitize_svg(blob).data).to eq(expected) + expect(sanitize_svg_data(data)).to eq(expected) end end @@ -105,4 +104,119 @@ describe BlobHelper do expect(Capybara.string(link).find_link('Edit')[:href]).to eq('/gitlab/gitlabhq/edit/master/README.md?mr_id=10') end end + + context 'viewer related' do + include FakeBlobHelpers + + let(:project) { build(:empty_project, lfs_enabled: true) } + + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + end + + let(:viewer_class) do + Class.new(BlobViewer::Base) do + self.max_size = 1.megabyte + self.absolute_max_size = 5.megabytes + self.type = :rich + self.client_side = false + end + end + + let(:viewer) { viewer_class.new(blob) } + let(:blob) { fake_blob } + + 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 + let(:blob) { fake_blob(size: 10.megabytes) } + + it 'returns an error message' do + expect(helper.blob_render_error_reason(viewer)).to eq('it is larger than 5 MB') + end + end + + context 'when the blob size is larger than the max size' do + let(:blob) { fake_blob(size: 2.megabytes) } + + it 'returns an error message' do + expect(helper.blob_render_error_reason(viewer)).to eq('it is larger than 1 MB') + end + end + end + + context 'for error :server_side_but_stored_in_lfs' do + let(:blob) { fake_blob(lfs: true) } + + it 'returns an error message' do + expect(helper.blob_render_error_reason(viewer)).to eq('it is stored in LFS') + end + end + end + + describe '#blob_render_error_options' do + before do + assign(:project, project) + assign(:id, File.join('master', blob.path)) + + controller.params[:controller] = 'projects/blob' + controller.params[:action] = 'show' + controller.params[:namespace_id] = project.namespace.to_param + controller.params[:project_id] = project.to_param + 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) } + + 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) } + + it 'does not include a "load it anyway" link' do + expect(helper.blob_render_error_options(viewer)).not_to include(/load it anyway/) + end + end + end + + context 'when the viewer is rich' do + context 'the blob is rendered as text' do + let(:blob) { fake_blob(path: 'file.md', lfs: true) } + + it 'includes a "view the source" link' do + expect(helper.blob_render_error_options(viewer)).to include(/view the source/) + end + end + + context 'the blob is not rendered as text' do + let(:blob) { fake_blob(path: 'file.pdf', binary: true, lfs: true) } + + it 'does not include a "view the source" link' do + expect(helper.blob_render_error_options(viewer)).not_to include(/view the source/) + end + end + end + + context 'when the viewer is not rich' do + before do + viewer_class.type = :simple + end + + let(:blob) { fake_blob(path: 'file.md', lfs: true) } + + it 'does not include a "view the source" link' do + expect(helper.blob_render_error_options(viewer)).not_to include(/view the source/) + end + end + + it 'includes a "download it" link' do + expect(helper.blob_render_error_options(viewer)).to include(/download it/) + end + end + end end diff --git a/spec/javascripts/blob/viewer/index_spec.js b/spec/javascripts/blob/viewer/index_spec.js new file mode 100644 index 00000000000..13f122b68b2 --- /dev/null +++ b/spec/javascripts/blob/viewer/index_spec.js @@ -0,0 +1,161 @@ +/* eslint-disable no-new */ +import BlobViewer from '~/blob/viewer/index'; + +describe('Blob viewer', () => { + let blob; + preloadFixtures('blob/show.html.raw'); + + beforeEach(() => { + loadFixtures('blob/show.html.raw'); + $('#modal-upload-blob').remove(); + + blob = new BlobViewer(); + + spyOn($, 'ajax').and.callFake(() => { + const d = $.Deferred(); + + d.resolve({ + html: '<div>testing</div>', + }); + + return d.promise(); + }); + }); + + afterEach(() => { + location.hash = ''; + }); + + it('loads source file after switching views', (done) => { + document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); + + setTimeout(() => { + expect($.ajax).toHaveBeenCalled(); + expect( + document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]') + .classList.contains('hidden'), + ).toBeFalsy(); + + done(); + }); + }); + + it('loads source file when line number is in hash', (done) => { + location.hash = '#L1'; + + new BlobViewer(); + + setTimeout(() => { + expect($.ajax).toHaveBeenCalled(); + expect( + document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]') + .classList.contains('hidden'), + ).toBeFalsy(); + + done(); + }); + }); + + it('doesnt reload file if already loaded', (done) => { + const asyncClick = () => new Promise((resolve) => { + document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); + + setTimeout(resolve); + }); + + asyncClick() + .then(() => { + expect($.ajax).toHaveBeenCalled(); + return asyncClick(); + }) + .then(() => { + expect($.ajax.calls.count()).toBe(1); + expect( + document.querySelector('.blob-viewer[data-type="simple"]').getAttribute('data-loaded'), + ).toBe('true'); + + done(); + }) + .catch(() => { + fail(); + done(); + }); + }); + + describe('copy blob button', () => { + it('disabled on load', () => { + expect( + document.querySelector('.js-copy-blob-source-btn').classList.contains('disabled'), + ).toBeTruthy(); + }); + + it('has tooltip when disabled', () => { + expect( + document.querySelector('.js-copy-blob-source-btn').getAttribute('data-original-title'), + ).toBe('Switch to the source to copy it to the clipboard'); + }); + + it('enables after switching to simple view', (done) => { + document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); + + setTimeout(() => { + expect($.ajax).toHaveBeenCalled(); + expect( + document.querySelector('.js-copy-blob-source-btn').classList.contains('disabled'), + ).toBeFalsy(); + + done(); + }); + }); + + it('updates tooltip after switching to simple view', (done) => { + document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); + + setTimeout(() => { + expect($.ajax).toHaveBeenCalled(); + + expect( + document.querySelector('.js-copy-blob-source-btn').getAttribute('data-original-title'), + ).toBe('Copy source to clipboard'); + + done(); + }); + }); + }); + + describe('switchToViewer', () => { + it('removes active class from old viewer button', () => { + blob.switchToViewer('simple'); + + expect( + document.querySelector('.js-blob-viewer-switch-btn.active[data-viewer="rich"]'), + ).toBeNull(); + }); + + it('adds active class to new viewer button', () => { + const simpleBtn = document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]'); + + spyOn(simpleBtn, 'blur'); + + blob.switchToViewer('simple'); + + expect( + simpleBtn.classList.contains('active'), + ).toBeTruthy(); + expect(simpleBtn.blur).toHaveBeenCalled(); + }); + + it('sends AJAX request when switching to simple view', () => { + blob.switchToViewer('simple'); + + expect($.ajax).toHaveBeenCalled(); + }); + + it('does not send AJAX request when switching to rich view', () => { + blob.switchToViewer('simple'); + blob.switchToViewer('rich'); + + expect($.ajax.calls.count()).toBe(1); + }); + }); +}); diff --git a/spec/javascripts/fixtures/blob.rb b/spec/javascripts/fixtures/blob.rb new file mode 100644 index 00000000000..16490ad5039 --- /dev/null +++ b/spec/javascripts/fixtures/blob.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +describe Projects::BlobController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project, :repository, namespace: namespace, path: 'branches-project') } + + render_views + + before(:all) do + clean_frontend_fixtures('blob/') + end + + before(:each) do + sign_in(admin) + end + + it 'blob/show.html.raw' do |example| + get(:show, + namespace_id: project.namespace, + project_id: project, + id: 'add-ipython-files/files/ipython/basic.ipynb') + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index e5dd57fc4bb..7e8a1c8add7 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -2,6 +2,14 @@ require 'rails_helper' describe Blob do + include FakeBlobHelpers + + let(:project) { build(:empty_project, lfs_enabled: true) } + + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + end + describe '.decorate' do it 'returns NilClass when given nil' do expect(described_class.decorate(nil)).to be_nil @@ -12,7 +20,7 @@ describe Blob do context 'using a binary blob' do it 'returns the data as-is' do data = "\n\xFF\xB9\xC3" - blob = described_class.new(double(binary?: true, data: data)) + blob = fake_blob(binary: true, data: data) expect(blob.data).to eq(data) end @@ -20,202 +28,176 @@ describe Blob do context 'using a text blob' do it 'converts the data to UTF-8' do - blob = described_class.new(double(binary?: false, data: "\n\xFF\xB9\xC3")) + blob = fake_blob(binary: false, data: "\n\xFF\xB9\xC3") expect(blob.data).to eq("\n���") end end end - describe '#svg?' do - it 'is falsey when not text' do - git_blob = double(text?: false) + describe '#raw_binary?' do + context 'if the blob is a valid LFS pointer' do + context 'if the extension has a rich viewer' do + context 'if the viewer is binary' do + it 'returns true' do + blob = fake_blob(path: 'file.pdf', lfs: true) - expect(described_class.decorate(git_blob)).not_to be_svg - end - - it 'is falsey when no language is detected' do - git_blob = double(text?: true, language: nil) + expect(blob.raw_binary?).to be_truthy + end + end - expect(described_class.decorate(git_blob)).not_to be_svg - end + context 'if the viewer is text-based' do + it 'return false' do + blob = fake_blob(path: 'file.md', lfs: true) - it' is falsey when language is not SVG' do - git_blob = double(text?: true, language: double(name: 'XML')) - - expect(described_class.decorate(git_blob)).not_to be_svg - end - - it 'is truthy when language is SVG' do - git_blob = double(text?: true, language: double(name: 'SVG')) - - expect(described_class.decorate(git_blob)).to be_svg - end - end - - describe '#pdf?' do - it 'is falsey when file extension is not .pdf' do - git_blob = Gitlab::Git::Blob.new(name: 'git_blob.txt') - - expect(described_class.decorate(git_blob)).not_to be_pdf - end + expect(blob.raw_binary?).to be_falsey + end + end + end - it 'is truthy when file extension is .pdf' do - git_blob = Gitlab::Git::Blob.new(name: 'git_blob.pdf') + context "if the extension doesn't have a rich viewer" do + it 'returns true' do + blob = fake_blob(path: 'file.exe', lfs: true) - expect(described_class.decorate(git_blob)).to be_pdf + expect(blob.raw_binary?).to be_truthy + end + end end - end - describe '#ipython_notebook?' do - it 'is falsey when language is not Jupyter Notebook' do - git_blob = double(text?: true, language: double(name: 'JSON')) + context 'if the blob is not an LFS pointer' do + context 'if the blob is binary' do + it 'returns true' do + blob = fake_blob(path: 'file.pdf', binary: true) - expect(described_class.decorate(git_blob)).not_to be_ipython_notebook - end + expect(blob.raw_binary?).to be_truthy + end + end - it 'is truthy when language is Jupyter Notebook' do - git_blob = double(text?: true, language: double(name: 'Jupyter Notebook')) + context 'if the blob is text-based' do + it 'return false' do + blob = fake_blob(path: 'file.md') - expect(described_class.decorate(git_blob)).to be_ipython_notebook + expect(blob.raw_binary?).to be_falsey + end + end end end - describe '#sketch?' do - it 'is falsey with image extension' do - git_blob = Gitlab::Git::Blob.new(name: "design.png") - - expect(described_class.decorate(git_blob)).not_to be_sketch - end - - it 'is truthy with sketch extension' do - git_blob = Gitlab::Git::Blob.new(name: "design.sketch") + describe '#extension' do + it 'returns the extension' do + blob = fake_blob(path: 'file.md') - expect(described_class.decorate(git_blob)).to be_sketch + expect(blob.extension).to eq('md') end end - describe '#video?' do - it 'is falsey with image extension' do - git_blob = Gitlab::Git::Blob.new(name: 'image.png') + describe '#simple_viewer' do + context 'when the blob is empty' do + it 'returns an empty viewer' do + blob = fake_blob(data: '') - expect(described_class.decorate(git_blob)).not_to be_video - end - - UploaderHelper::VIDEO_EXT.each do |ext| - it "is truthy when extension is .#{ext}" do - git_blob = Gitlab::Git::Blob.new(name: "video.#{ext}") - - expect(described_class.decorate(git_blob)).to be_video + expect(blob.simple_viewer).to be_a(BlobViewer::Empty) end end - end - describe '#stl?' do - it 'is falsey with image extension' do - git_blob = Gitlab::Git::Blob.new(name: 'file.png') + context 'when the file represented by the blob is binary' do + it 'returns a download viewer' do + blob = fake_blob(binary: true) - expect(described_class.decorate(git_blob)).not_to be_stl + expect(blob.simple_viewer).to be_a(BlobViewer::Download) + end end - it 'is truthy with STL extension' do - git_blob = Gitlab::Git::Blob.new(name: 'file.stl') + context 'when the file represented by the blob is text-based' do + it 'returns a text viewer' do + blob = fake_blob - expect(described_class.decorate(git_blob)).to be_stl + expect(blob.simple_viewer).to be_a(BlobViewer::Text) + end end end - describe '#to_partial_path' do - let(:project) { double(lfs_enabled?: true) } + describe '#rich_viewer' do + context 'when the blob is an invalid LFS pointer' do + before do + project.lfs_enabled = false + end - def stubbed_blob(overrides = {}) - overrides.reverse_merge!( - name: nil, - image?: false, - language: nil, - lfs_pointer?: false, - svg?: false, - text?: false, - binary?: false, - stl?: false - ) + it 'returns nil' do + blob = fake_blob(path: 'file.pdf', lfs: true) - described_class.decorate(Gitlab::Git::Blob.new({})).tap do |blob| - allow(blob).to receive_messages(overrides) + expect(blob.rich_viewer).to be_nil end end - it 'handles LFS pointers with LFS enabled' do - blob = stubbed_blob(lfs_pointer?: true, text?: true) - expect(blob.to_partial_path(project)).to eq 'download' - end - - it 'handles LFS pointers with LFS disabled' do - blob = stubbed_blob(lfs_pointer?: true, text?: true) - project = double(lfs_enabled?: false) - expect(blob.to_partial_path(project)).to eq 'text' - end + context 'when the blob is empty' do + it 'returns nil' do + blob = fake_blob(data: '') - it 'handles SVGs' do - blob = stubbed_blob(text?: true, svg?: true) - expect(blob.to_partial_path(project)).to eq 'svg' + expect(blob.rich_viewer).to be_nil + end end - it 'handles images' do - blob = stubbed_blob(image?: true) - expect(blob.to_partial_path(project)).to eq 'image' - end + context 'when the blob is a valid LFS pointer' do + it 'returns a matching viewer' do + blob = fake_blob(path: 'file.pdf', lfs: true) - it 'handles text' do - blob = stubbed_blob(text?: true, name: 'test.txt') - expect(blob.to_partial_path(project)).to eq 'text' - end - - it 'defaults to download' do - blob = stubbed_blob - expect(blob.to_partial_path(project)).to eq 'download' + expect(blob.rich_viewer).to be_a(BlobViewer::PDF) + end end - it 'handles PDFs' do - blob = stubbed_blob(name: 'blob.pdf', pdf?: true) - expect(blob.to_partial_path(project)).to eq 'pdf' - end + context 'when the blob is binary' do + it 'returns a matching binary viewer' do + blob = fake_blob(path: 'file.pdf', binary: true) - it 'handles iPython notebooks' do - blob = stubbed_blob(text?: true, ipython_notebook?: true) - expect(blob.to_partial_path(project)).to eq 'notebook' + expect(blob.rich_viewer).to be_a(BlobViewer::PDF) + end end - it 'handles Sketch files' do - blob = stubbed_blob(text?: true, sketch?: true, binary?: true) - expect(blob.to_partial_path(project)).to eq 'sketch' - end + context 'when the blob is text-based' do + it 'returns a matching text-based viewer' do + blob = fake_blob(path: 'file.md') - it 'handles STLs' do - blob = stubbed_blob(text?: true, stl?: true) - expect(blob.to_partial_path(project)).to eq 'stl' + expect(blob.rich_viewer).to be_a(BlobViewer::Markup) + end end end - describe '#size_within_svg_limits?' do - let(:blob) { described_class.decorate(double(:blob)) } + describe '#rendered_as_text?' do + context 'when ignoring errors' do + context 'when the simple viewer is text-based' do + it 'returns true' do + blob = fake_blob(path: 'file.md', size: 100.megabytes) - it 'returns true when the blob size is smaller than the SVG limit' do - expect(blob).to receive(:size).and_return(42) + expect(blob.rendered_as_text?).to be_truthy + end + end + + context 'when the simple viewer is binary' do + it 'returns false' do + blob = fake_blob(path: 'file.pdf', binary: true, size: 100.megabytes) - expect(blob.size_within_svg_limits?).to eq(true) + expect(blob.rendered_as_text?).to be_falsey + end + end end - it 'returns true when the blob size is equal to the SVG limit' do - expect(blob).to receive(:size).and_return(Blob::MAXIMUM_SVG_SIZE) + context 'when not ignoring errors' do + context 'when the viewer has render errors' do + it 'returns false' do + blob = fake_blob(path: 'file.md', size: 100.megabytes) - expect(blob.size_within_svg_limits?).to eq(true) - end + expect(blob.rendered_as_text?(ignore_errors: false)).to be_falsey + end + end - it 'returns false when the blob size is larger than the SVG limit' do - expect(blob).to receive(:size).and_return(1.terabyte) + context "when the viewer doesn't have render errors" do + it 'returns true' do + blob = fake_blob(path: 'file.md') - expect(blob.size_within_svg_limits?).to eq(false) + expect(blob.rendered_as_text?(ignore_errors: false)).to be_truthy + end + end end end end diff --git a/spec/models/blob_viewer/base_spec.rb b/spec/models/blob_viewer/base_spec.rb new file mode 100644 index 00000000000..a3e598de56d --- /dev/null +++ b/spec/models/blob_viewer/base_spec.rb @@ -0,0 +1,186 @@ +require 'spec_helper' + +describe BlobViewer::Base, model: true do + include FakeBlobHelpers + + let(:project) { build(:empty_project) } + + let(:viewer_class) do + Class.new(described_class) do + self.extensions = %w(pdf) + self.max_size = 1.megabyte + self.absolute_max_size = 5.megabytes + self.client_side = false + end + end + + let(:viewer) { viewer_class.new(blob) } + + describe '.can_render?' do + context 'when the extension is supported' do + let(:blob) { fake_blob(path: 'file.pdf') } + + it 'returns true' do + expect(viewer_class.can_render?(blob)).to be_truthy + end + end + + context 'when the extension is not supported' do + let(:blob) { fake_blob(path: 'file.txt') } + + it 'returns false' do + expect(viewer_class.can_render?(blob)).to be_falsey + end + end + end + + describe '#too_large?' do + context 'when the blob size is larger than the max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } + + it 'returns true' do + expect(viewer.too_large?).to be_truthy + end + end + + context 'when the blob size is smaller than the max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } + + it 'returns false' do + expect(viewer.too_large?).to be_falsey + end + end + end + + describe '#absolutely_too_large?' do + context 'when the blob size is larger than the absolute max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } + + it 'returns true' do + expect(viewer.absolutely_too_large?).to be_truthy + end + end + + context 'when the blob size is smaller than the absolute max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } + + it 'returns false' do + expect(viewer.absolutely_too_large?).to be_falsey + end + end + end + + describe '#can_override_max_size?' do + context 'when the blob size is larger than the max size' do + context 'when the blob size is larger than the absolute 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 absolute 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 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 + end + end + end + + describe '#render_error' do + context 'when the max size is overridden' do + before do + viewer.override_max_size = true + end + + context 'when the blob size is larger than the absolute max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } + + it 'returns :too_large' do + expect(viewer.render_error).to eq(:too_large) + end + end + + context 'when the blob size is smaller than the absolute max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } + + it 'returns nil' do + expect(viewer.render_error).to be_nil + end + end + end + + context 'when the max size is not overridden' do + context 'when the blob size is larger than the max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } + + it 'returns :too_large' do + expect(viewer.render_error).to eq(:too_large) + end + end + + context 'when the blob size is smaller than the max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } + + it 'returns nil' do + expect(viewer.render_error).to be_nil + end + end + end + + context 'when the viewer is server side but the blob is stored in LFS' do + let(:project) { build(:empty_project, lfs_enabled: true) } + + let(:blob) { fake_blob(path: 'file.pdf', lfs: true) } + + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + end + + it 'return :server_side_but_stored_in_lfs' do + expect(viewer.render_error).to eq(:server_side_but_stored_in_lfs) + end + end + end + + describe '#prepare!' do + context 'when the viewer is server side' do + let(:blob) { fake_blob(path: 'file.md') } + + before do + viewer_class.client_side = false + end + + it 'loads all blob data' do + expect(blob).to receive(:load_all_data!) + + viewer.prepare! + end + end + + context 'when the viewer is client side' do + let(:blob) { fake_blob(path: 'file.md') } + + before do + viewer_class.client_side = true + end + + it "doesn't load all blob data" do + expect(blob).not_to receive(:load_all_data!) + + viewer.prepare! + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 0a2860f2505..0bcebc27598 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -1556,6 +1556,16 @@ describe User, models: true do expect(ghost.email).to eq('ghost1@example.com') end end + + context 'when a domain whitelist is in place' do + before do + stub_application_setting(domain_whitelist: ['gitlab.com']) + end + + it 'creates a ghost user' do + expect(User.ghost).to be_persisted + end + end end describe '#update_two_factor_requirement' do diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index a63281f0eab..29e65fe7ce6 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -52,7 +52,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'unassign command' do it 'populates assignee_id: nil if content contains /unassign' do - issuable.update(assignee_id: developer.id) + issuable.update!(assignee_id: developer.id) _, updates = service.execute(content, issuable) expect(updates).to eq(assignee_id: nil) @@ -70,7 +70,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'remove_milestone command' do it 'populates milestone_id: nil if content contains /remove_milestone' do - issuable.update(milestone_id: milestone.id) + issuable.update!(milestone_id: milestone.id) _, updates = service.execute(content, issuable) expect(updates).to eq(milestone_id: nil) @@ -108,7 +108,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'unlabel command' do it 'fetches label ids and populates remove_label_ids if content contains /unlabel' do - issuable.update(label_ids: [inprogress.id]) # populate the label + issuable.update!(label_ids: [inprogress.id]) # populate the label _, updates = service.execute(content, issuable) expect(updates).to eq(remove_label_ids: [inprogress.id]) @@ -117,7 +117,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'multiple unlabel command' do it 'fetches label ids and populates remove_label_ids if content contains mutiple /unlabel' do - issuable.update(label_ids: [inprogress.id, bug.id]) # populate the label + issuable.update!(label_ids: [inprogress.id, bug.id]) # populate the label _, updates = service.execute(content, issuable) expect(updates).to eq(remove_label_ids: [inprogress.id, bug.id]) @@ -126,7 +126,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'unlabel command with no argument' do it 'populates label_ids: [] if content contains /unlabel with no arguments' do - issuable.update(label_ids: [inprogress.id]) # populate the label + issuable.update!(label_ids: [inprogress.id]) # populate the label _, updates = service.execute(content, issuable) expect(updates).to eq(label_ids: []) @@ -135,7 +135,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'relabel command' do it 'populates label_ids: [] if content contains /relabel' do - issuable.update(label_ids: [bug.id]) # populate the label + issuable.update!(label_ids: [bug.id]) # populate the label inprogress # populate the label _, updates = service.execute(content, issuable) @@ -187,7 +187,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'remove_due_date command' do it 'populates due_date: nil if content contains /remove_due_date' do - issuable.update(due_date: Date.today) + issuable.update!(due_date: Date.today) _, updates = service.execute(content, issuable) expect(updates).to eq(due_date: nil) @@ -204,7 +204,7 @@ describe SlashCommands::InterpretService, services: true do shared_examples 'unwip command' do it 'returns wip_event: "unwip" if content contains /wip' do - issuable.update(title: issuable.wip_title) + issuable.update!(title: issuable.wip_title) _, updates = service.execute(content, issuable) expect(updates).to eq(wip_event: 'unwip') @@ -727,5 +727,75 @@ describe SlashCommands::InterpretService, services: true do end end end + + context '/board_move command' do + let(:todo) { create(:label, project: project, title: 'To Do') } + let(:inreview) { create(:label, project: project, title: 'In Review') } + let(:content) { %{/board_move ~"#{inreview.title}"} } + + let!(:board) { create(:board, project: project) } + let!(:todo_list) { create(:list, board: board, label: todo) } + let!(:inreview_list) { create(:list, board: board, label: inreview) } + let!(:inprogress_list) { create(:list, board: board, label: inprogress) } + + it 'populates remove_label_ids for all current board columns' do + issue.update!(label_ids: [todo.id, inprogress.id]) + + _, updates = service.execute(content, issue) + + expect(updates[:remove_label_ids]).to match_array([todo.id, inprogress.id]) + end + + it 'populates add_label_ids with the id of the given label' do + _, updates = service.execute(content, issue) + + expect(updates[:add_label_ids]).to eq([inreview.id]) + end + + it 'does not include the given label id in remove_label_ids' do + issue.update!(label_ids: [todo.id, inreview.id]) + + _, updates = service.execute(content, issue) + + expect(updates[:remove_label_ids]).to match_array([todo.id]) + end + + it 'does not remove label ids that are not lists on the board' do + issue.update!(label_ids: [todo.id, bug.id]) + + _, updates = service.execute(content, issue) + + expect(updates[:remove_label_ids]).to match_array([todo.id]) + end + + context 'if the project has multiple boards' do + let(:issuable) { issue } + before { create(:board, project: project) } + it_behaves_like 'empty command' + end + + context 'if the given label does not exist' do + let(:issuable) { issue } + let(:content) { '/board_move ~"Fake Label"' } + it_behaves_like 'empty command' + end + + context 'if multiple labels are given' do + let(:issuable) { issue } + let(:content) { %{/board_move ~"#{inreview.title}" ~"#{todo.title}"} } + it_behaves_like 'empty command' + end + + context 'if the given label is not a list on the board' do + let(:issuable) { issue } + let(:content) { %{/board_move ~"#{bug.title}"} } + it_behaves_like 'empty command' + end + + context 'if issuable is not an Issue' do + let(:issuable) { merge_request } + it_behaves_like 'empty command' + end + end end end diff --git a/spec/support/helpers/fake_blob_helpers.rb b/spec/support/helpers/fake_blob_helpers.rb new file mode 100644 index 00000000000..b29af732ad3 --- /dev/null +++ b/spec/support/helpers/fake_blob_helpers.rb @@ -0,0 +1,50 @@ +module FakeBlobHelpers + class FakeBlob + include Linguist::BlobHelper + + attr_reader :path, :size, :data, :lfs_oid, :lfs_size + + def initialize(path: 'file.txt', size: 1.kilobyte, data: 'foo', binary: false, lfs: nil) + @path = path + @size = size + @data = data + @binary = binary + + @lfs_pointer = lfs.present? + if @lfs_pointer + @lfs_oid = SecureRandom.hex(20) + @lfs_size = 1.megabyte + end + end + + alias_method :name, :path + + def mode + nil + end + + def id + 0 + end + + def binary? + @binary + end + + def load_all_data!(repository) + # No-op + end + + def lfs_pointer? + @lfs_pointer + end + + def truncated? + false + end + end + + def fake_blob(**kwargs) + Blob.decorate(FakeBlob.new(**kwargs), project) + end +end diff --git a/spec/support/matchers/access_matchers.rb b/spec/support/matchers/access_matchers.rb index 7d238850520..3e4ca8b7ab0 100644 --- a/spec/support/matchers/access_matchers.rb +++ b/spec/support/matchers/access_matchers.rb @@ -51,7 +51,7 @@ module AccessMatchers emulate_user(user, @membership) visit(url) - status_code != 404 && current_path != new_user_session_path + status_code == 200 && current_path != new_user_session_path end chain :of do |membership| @@ -66,7 +66,7 @@ module AccessMatchers emulate_user(user, @membership) visit(url) - status_code == 404 || current_path == new_user_session_path + [401, 404].include?(status_code) || current_path == new_user_session_path end chain :of do |membership| diff --git a/spec/unicorn/unicorn_spec.rb b/spec/unicorn/unicorn_spec.rb new file mode 100644 index 00000000000..8518c047a47 --- /dev/null +++ b/spec/unicorn/unicorn_spec.rb @@ -0,0 +1,98 @@ +require 'fileutils' + +require 'excon' + +require 'spec_helper' + +describe 'Unicorn' do + before(:all) do + config_lines = File.read('config/unicorn.rb.example').split("\n") + + # Remove these because they make setup harder. + config_lines = config_lines.reject do |line| + %w[ + working_directory + worker_processes + listen + pid + stderr_path + stdout_path + ].any? { |prefix| line.start_with?(prefix) } + end + + config_lines << "working_directory '#{Rails.root}'" + + # We want to have exactly 1 worker process because that makes it + # predictable which process will handle our requests. + config_lines << 'worker_processes 1' + + @socket_path = File.join(Dir.pwd, 'tmp/tests/unicorn.socket') + config_lines << "listen '#{@socket_path}'" + + ready_file = 'tmp/tests/unicorn-worker-ready' + FileUtils.rm_f(ready_file) + after_fork_index = config_lines.index { |l| l.start_with?('after_fork') } + config_lines.insert(after_fork_index + 1, "File.write('#{ready_file}', Process.pid)") + + config_path = 'tmp/tests/unicorn.rb' + File.write(config_path, config_lines.join("\n") + "\n") + + cmd = %W[unicorn -E test -c #{config_path} #{Rails.root.join('config.ru')}] + @unicorn_master_pid = spawn(*cmd) + wait_unicorn_boot!(@unicorn_master_pid, ready_file) + WebMock.allow_net_connect! + end + + %w[SIGQUIT SIGTERM SIGKILL].each do |signal| + it "has a worker that self-terminates on signal #{signal}" do + response = Excon.get('unix:///unicorn_test/pid', socket: @socket_path) + expect(response.status).to eq(200) + + worker_pid = response.body.to_i + expect(worker_pid).to be > 0 + + begin + Excon.post('unix:///unicorn_test/kill', socket: @socket_path, body: "signal=#{signal}") + rescue Excon::Error::Socket + # The connection may be closed abruptly + end + + expect(pid_gone?(worker_pid)).to eq(true) + end + end + + after(:all) do + WebMock.disable_net_connect!(allow_localhost: true) + Process.kill('TERM', @unicorn_master_pid) + end + + def wait_unicorn_boot!(master_pid, ready_file) + # Unicorn should boot in under 60 seconds so 120 seconds seems like a good timeout. + timeout = 120 + timeout.times do + return if File.exist?(ready_file) + pid = Process.waitpid(master_pid, Process::WNOHANG) + raise "unicorn failed to boot: #{$?}" unless pid.nil? + + sleep 1 + end + + raise "unicorn boot timed out after #{timeout} seconds" + end + + def pid_gone?(pid) + # Worker termination should take less than a second. That makes 10 + # seconds a generous timeout. + 10.times do + begin + Process.kill(0, pid) + rescue Errno::ESRCH + return true + end + + sleep 1 + end + + false + end +end diff --git a/spec/views/projects/blob/_viewer.html.haml_spec.rb b/spec/views/projects/blob/_viewer.html.haml_spec.rb new file mode 100644 index 00000000000..a4915264abe --- /dev/null +++ b/spec/views/projects/blob/_viewer.html.haml_spec.rb @@ -0,0 +1,96 @@ +require 'spec_helper' + +describe 'projects/blob/_viewer.html.haml', :view do + include FakeBlobHelpers + + let(:project) { build(:empty_project) } + + let(:viewer_class) do + Class.new(BlobViewer::Base) do + include BlobViewer::Rich + + self.partial_name = 'text' + self.max_size = 1.megabyte + self.absolute_max_size = 5.megabytes + self.client_side = false + end + end + + let(:viewer) { viewer_class.new(blob) } + let(:blob) { fake_blob } + + before do + assign(:project, project) + assign(:id, File.join('master', blob.path)) + + controller.params[:controller] = 'projects/blob' + controller.params[:action] = 'show' + controller.params[:namespace_id] = project.namespace.to_param + controller.params[:project_id] = project.to_param + controller.params[:id] = File.join('master', blob.path) + end + + def render_view + render partial: 'projects/blob/viewer', locals: { viewer: viewer } + end + + context 'when the viewer is server side' do + before do + viewer_class.client_side = false + end + + context 'when there is no render error' do + it 'adds a URL to the blob viewer element' do + render_view + + expect(rendered).to have_css('.blob-viewer[data-url]') + end + + it 'displays a spinner' do + render_view + + expect(rendered).to have_css('i[aria-label="Loading content"]') + end + end + + context 'when there is a render error' do + let(:blob) { fake_blob(size: 10.megabytes) } + + it 'renders the error' do + render_view + + expect(view).to render_template('projects/blob/_render_error') + end + end + end + + context 'when the viewer is client side' do + before do + viewer_class.client_side = true + end + + context 'when there is no render error' do + it 'prepares the viewer' do + expect(viewer).to receive(:prepare!) + + render_view + end + + it 'renders the viewer' do + render_view + + expect(view).to render_template('projects/blob/viewers/_text') + end + end + + context 'when there is a render error' do + let(:blob) { fake_blob(size: 10.megabytes) } + + it 'renders the error' do + render_view + + expect(view).to render_template('projects/blob/_render_error') + end + end + end +end |