diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 230ca698ad07eb47a7d0d81d7f31fd87b2f5afa9..50f1f65e4e8e20c1c5a4e14cf38d253e887d91ed 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -403,13 +403,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 diff --git a/README.md b/README.md index 10d69efdc6bf3d35473de4a828935a96a087b7c7..59de828e1ac6d5812da6e8eda500c170e789769c 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [](https://gitlab.com/gitlab-org/gitlab-ce/pipelines) [](https://codeclimate.com/github/gitlabhq/gitlabhq) [](https://bestpractices.coreinfrastructure.org/projects/42) +[](https://gitter.im/gitlabhq/gitlabhq?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) ## Test coverage diff --git a/app/assets/javascripts/blob/pdf/index.js b/app/assets/javascripts/blob/pdf/index.js index a74c2db9a61f181ee97afb4f54de7fb2b6433079..9161be98853f3b86bc5f8e31dca8ca815569cdda 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 0000000000000000000000000000000000000000..7efa85372982e7366e2bea3c7acf1f5dc7508433 --- /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 b20673cd03c8dd90ae7da17a867805da97272218..d3d75c4bf4a1582f76d11348add6eec982dd0598 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -48,6 +48,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; import UserCallout from './user_callout'; import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; import ShortcutsWiki from './shortcuts_wiki'; +import BlobViewer from './blob/viewer/index'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -299,6 +300,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 1821ca1805376dcc647238d951c4b1713724768f..a6f7bea99f5735fa879b143c3c6df7f13175a270 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 a668a6c4c398e8075a2438865640b76529d20b0e..80691a234f8cf1e10e5783eb94210ac4e39f846c 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 b3340d4133338e3fbd525ca808b97dd1a811da6e..3a98332e46c811ddf9b7c7306630fa2d5fd83b4f 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -12,6 +12,13 @@ max-width: $max_width; } +/* + * Mixin for markdown tables + */ +@mixin markdown-table { + width: auto; +} + /* * Base mixin for lists in GitLab */ diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 411f1c4442b72efd8a95539fd38567457d05c30a..724b4080ee0c238d3355c39afef8b3d52677bd47 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 b2f45625a2a462768ff947218e2da638cab81895..2aa52986e0a6db7269720d0b4a7866ade49da16a 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 69a95db692068ecffa7afc617c8643a30d2e9160..7cf74502a3a4b38aa7bae51f5f4049f48b66c4cd 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 9bc47bbe173a92d8f1ae76e109af211f65be5c66..04ff2d52b91d2725f2b9338e2bd89217e99c61cc 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 e77094fe2a826f60a57cf26e9e46c3efee3939ec..e48f0963ef455f5f2ef50ac4c6bc4ef2370e30b9 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 0000000000000000000000000000000000000000..d478c3bb6caf89a0ca8135f1a6f0c9b61464ce06 --- /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 9fce1db6742d1d3f26e550b52bace0889e5b937a..be5822b2cd4afe06a014c15920e903724ce1ec6c 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 04e8cdf6256ac06990d6f39eac4fdb15882a2cd5..e24fc45d1666061a787325bed876eec5e78d053b 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 c55b37ae0dd6359402d5420186935678309e8420..a0b08ad130fe9524bf6a52750a2e58ce624b947a 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/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 36b16421e8f0788026ab67d9dbe63f43861e9e7c..cc47654dc063cce31c7114149e2aae4eeca24565 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 82333b6f3696c326fd3aba1072e038db66fccef8..1694f4e6dc9654b3b914e997b6278f853210e76e 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -3,8 +3,41 @@ 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::Balsamiq, + + 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 +49,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,88 +74,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 balsamiq? - binary? && extension == 'bmpr' + def simple_viewer + @simple_viewer ||= simple_viewer_class.new(self) end - def stl? - extension == 'stl' + def rich_viewer + return @rich_viewer if defined?(@rich_viewer) + + @rich_viewer = rich_viewer_class&.new(self) end - def markup? - text? && Gitlab::MarkupHelper.markup?(name) + def rendered_as_text?(ignore_errors: true) + simple_viewer.text? && (ignore_errors || simple_viewer.render_error.nil?) end - def size_within_svg_limits? - size <= MAXIMUM_SVG_SIZE + def show_viewer_switcher? + rendered_as_text? && rich_viewer end - def video? - UploaderHelper::VIDEO_EXT.include?(extname.downcase.delete('.')) + def override_max_size! + simple_viewer&.override_max_size = true + rich_viewer&.override_max_size = true 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 balsamiq? - 'bmpr' - elsif markup? - if only_display_raw? - 'too_large' - else - 'markup' - end - elsif text? - if only_display_raw? - 'too_large' - else - 'text' - end - else - 'download' + 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 0000000000000000000000000000000000000000..f944b00c9d3105c80ed8c897368c0a68a5b04a07 --- /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 0000000000000000000000000000000000000000..80393471ef2d16e993698ca4b636d3c82bbd6f95 --- /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 0000000000000000000000000000000000000000..42ec68f864bcb9b5fe50db20fc9312fba9fce0c6 --- /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 0000000000000000000000000000000000000000..adc06587f6981d3a40d3b6d66cc9f7220904ea11 --- /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 0000000000000000000000000000000000000000..d9d128eb273707625d480cbf0b40992e8ac2c728 --- /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 0000000000000000000000000000000000000000..c4eae5c79c2747001075ea4838734ba525e6b0b0 --- /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 0000000000000000000000000000000000000000..8fdbab30dd1a9a5e3e244b6ba0e67e0dc28242ca --- /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 0000000000000000000000000000000000000000..8632b8a9885f5ab56dc4e0e10d451587b4a542d9 --- /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 0000000000000000000000000000000000000000..65805f5f388fa2abd0300a3d027c7bc124741ab8 --- /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 0000000000000000000000000000000000000000..be373dbc948fb281b8cb4bf184073e8bbf7e8658 --- /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 0000000000000000000000000000000000000000..899107d02ea805b6dadb37e175f60cae703cfd4a --- /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 0000000000000000000000000000000000000000..454a20495fc361fa100dc781596e7c7e7bcd655a --- /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 0000000000000000000000000000000000000000..818456778e135d18a7af49a8e2f3f9dd3641e4ec --- /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 0000000000000000000000000000000000000000..b7e5cd71e6bef290705ca0e1be8da1c589d9dba8 --- /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 0000000000000000000000000000000000000000..e27b2c2b4932fe24bd306d7fd582069f006f79ba --- /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 0000000000000000000000000000000000000000..8184dc0104c8a645a7d15cdd5ef261cbba6b4b6b --- /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 8b8b3f002020db0f2b061582fdd973541d592c49..bb4cb8efd151977fc017957d7bb16728604d01d8 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/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index f2dfb87dbda98e866a84de9750a0fe912826484e..fa782c6fbb7885b56b84405461d00144228fea3b 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -22,7 +22,7 @@ class ChatNotificationService < Service end def can_test? - super && valid? + valid? end def self.supported_events diff --git a/app/models/repository.rb b/app/models/repository.rb index e74edb8e6f7c5fd47378589fc7614a0f9ab68976..d02aea49689b909fd4b80aebd97d0d20c2e86dd5 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/service.rb b/app/models/service.rb index dc76bf925d3d4f40a8e38a7b5a4f90dfbc862f50..c71a7d169eca72f2d9cd3b5f9bdb4231107e348f 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -26,6 +26,7 @@ class Service < ActiveRecord::Base has_one :service_hook validates :project_id, presence: true, unless: proc { |service| service.template? } + validates :type, presence: true scope :visible, -> { where.not(type: 'GitlabIssueTrackerService') } scope :issue_trackers, -> { where(category: 'issue_tracker') } @@ -131,7 +132,7 @@ class Service < ActiveRecord::Base end def can_test? - !project.empty_repo? + true end # reason why service cannot be tested diff --git a/app/models/user.rb b/app/models/user.rb index 774d4caa806cc7de31a0bbd193dc8ef482a2e10e..bd9c9f99663819e15491c0ba97f51c66f27d4ac0 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/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml index c6b1db17f91e9ce483a5a73952acc4b001f5ea46..02eb7c8462c58941e76d88eef325731a8a121c9a 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 9aafff343f0e5049da77c7d8b2b8aa626a8b34d8..3f12d64d044bc7115d7d45a7c7de6822e81c83df 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 0000000000000000000000000000000000000000..7afbd85cd6d4e50ebf37a541ffb8e7b48135a927 --- /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 7908fcae3de1db6f9c32aae52a2111a4c9f19799..0000000000000000000000000000000000000000 --- 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 c553db84ee0f7546ba857aec833a00242252d96e..b89cd460455a1489eb9c5ee8df778318b944d6eb 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 73877d730f5de93f98ca47c4687b5cb3815fae80..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..9eef6cafd04c955fc355f05c1d5f02d7c220eedb --- /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 93be58fc65826f614ec4edc9ec8a111cb793b561..0000000000000000000000000000000000000000 --- 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 20638f6961db4960d1934f9a804211427cfae2b5..0000000000000000000000000000000000000000 --- 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 a505f87df402b19d471caa1beb3114d51d7bb715..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..5326bb3e0cf070812ba73a4f068f3da9ca24eaaf --- /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 0000000000000000000000000000000000000000..6a521069418e79cb612a0feeea604138cbbc035f --- /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 b9b3f3ec7a3bcbbfcae7d429ca1e6235935da8d9..67f57b5e4b905d2e047fd09439957b05414cd32b 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 0000000000000000000000000000000000000000..684240d02c79720a6036af4e200f4443d1832a97 --- /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 0000000000000000000000000000000000000000..a293a8de231dbbad24879b30df5a4927a6c78e33 --- /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 0000000000000000000000000000000000000000..640d59b31745d875422ae6fc7e24ca152d7cf497 --- /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 0000000000000000000000000000000000000000..b9a998d96ffbfc0589988d6c65e76c55a6916b15 --- /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 similarity index 57% rename from app/views/projects/blob/_notebook.html.haml rename to app/views/projects/blob/viewers/_notebook.html.haml index ab1cf933944128e5d6fae47f5160a330622b09e6..2399fb1626533ba6309a46435d10bbdeb55b419a 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 similarity index 57% rename from app/views/projects/blob/_pdf.html.haml rename to app/views/projects/blob/viewers/_pdf.html.haml index 58dc88e3bf7e8400ab90f488a6bc459d4c41c193..1dd179c4fdc97ee455054cc5295ede56385af1eb 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 similarity index 74% rename from app/views/projects/blob/_sketch.html.haml rename to app/views/projects/blob/viewers/_sketch.html.haml index dad9369cb2ae935ab828bad18ceca8b78e34ad68..49f716c2c59eb5975514c419c79f95ceb72a46a0 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 similarity index 83% rename from app/views/projects/blob/_stl.html.haml rename to app/views/projects/blob/viewers/_stl.html.haml index a9332a0eeb6f7fc99c9ae1b11308812e48b65482..e4e9d7461766f016e9765ce4a7ee176848a418b7 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 0000000000000000000000000000000000000000..62f647581b614ebe3672f1bf3d38587681290532 --- /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 0000000000000000000000000000000000000000..a91df321ca087d90bced66cc270b1923cf3a2d7b --- /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 438a98c3e955bda2c05d789377ac00b07cc2c9be..c781e423c4d6571e6a9188a035cd0a3d9c7ec9b8 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 4b49bed835f6d41ee00333eeb67a282c8f61203a..71a1b9e6c05edb437b11ff1841223de83cf6d472 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 4622b9807549c7fd42a9b7f838c8d3d5ac025e84..f22b385fc0f632f500db84f73c53647163a60f0d 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/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index ccc808ff43e8246f2b9db358518eeb861e423cb2..774d20fb5badacd341999bc043b67f1c9bca4baf 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -89,7 +89,7 @@ .sidebar-collapsed-icon %strong = icon('exclamation', 'aria-hidden': 'true') - %span= milestone.issues_visible_to_user(current_user).count + %span= milestone.merge_requests.count .title.hide-collapsed Merge requests %span.badge= milestone.merge_requests.count diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 71ed23476d2a540016cba1a03b26049fc53d2787..cf0540afb38bbdbd8e85b168cd4e4d669da4439e 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 895c3f1e99d1c40c63e37a3cbed437511e233e40..37c66ff2595086f0dbbae15e1cbc3883b360387e 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/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 0000000000000000000000000000000000000000..7a3d687d73fbeab6b9c0ad4f8c8a1e2e4a92e3e3 --- /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/31138-improve-test-settings-for-services-in-empty-projects.yml b/changelogs/unreleased/31138-improve-test-settings-for-services-in-empty-projects.yml new file mode 100644 index 0000000000000000000000000000000000000000..cb1de425d66b7320226eaf121a2f8a87a7ecc8e4 --- /dev/null +++ b/changelogs/unreleased/31138-improve-test-settings-for-services-in-empty-projects.yml @@ -0,0 +1,4 @@ +--- +title: Improves test settings for chat notification services for empty projects +merge_request: 10886 +author: diff --git a/changelogs/unreleased/31292-milestone-sidebar-display-incorect-number-of-mr-when-minimized.yml b/changelogs/unreleased/31292-milestone-sidebar-display-incorect-number-of-mr-when-minimized.yml new file mode 100644 index 0000000000000000000000000000000000000000..dee831c668b40000b48d0bda8142641c205bac41 --- /dev/null +++ b/changelogs/unreleased/31292-milestone-sidebar-display-incorect-number-of-mr-when-minimized.yml @@ -0,0 +1,4 @@ +--- +title: Fixed milestone sidebar showing incorrect number of MRs when collapsed +merge_request: 10933 +author: diff --git a/changelogs/unreleased/dm-blob-viewers.yml b/changelogs/unreleased/dm-blob-viewers.yml new file mode 100644 index 0000000000000000000000000000000000000000..5e0d41f3f29828b6c3bfe5d9be379ba1300016c4 --- /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 0000000000000000000000000000000000000000..4214786cb5a76e43dd341a75162d1de0b606f148 --- /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 0000000000000000000000000000000000000000..95b6221f8d2b275b3f67b412a5ba2d3488d8a4a6 --- /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 0000000000000000000000000000000000000000..d03a26bdeb36d13b3c64602e3a804dc0378c2f33 --- /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 0000000000000000000000000000000000000000..198b6ce15ae6af1289493f89f1268dbd5d61d874 --- /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/project.rb b/config/routes/project.rb index fa92202c1eabd1f908a7fe63e4e8ec65f6110972..115ae2324b310e631c222a6fbdb02aee1e81d271 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/db/migrate/20170421102337_remove_nil_type_services.rb b/db/migrate/20170421102337_remove_nil_type_services.rb new file mode 100644 index 0000000000000000000000000000000000000000..b835b9c6ed99242fbc15dcb01baf1c5ca1ee06df --- /dev/null +++ b/db/migrate/20170421102337_remove_nil_type_services.rb @@ -0,0 +1,12 @@ +class RemoveNilTypeServices < ActiveRecord::Migration + DOWNTIME = false + + def up + execute <<-SQL + DELETE FROM services WHERE type IS NULL OR type = ''; + SQL + end + + def down + end +end diff --git a/doc/integration/chat_commands.md b/doc/integration/chat_commands.md index 4b0084678d91afe8b95dc31cff4a37dc01c9f93c..c878dc7e6500d9d1ef1f7b6d20a4bfe4895dfe40 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 96c91093d7d19756063ad535640bd059ba0c3400..31baea507d7921671412192711f6a772511ff739 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 e8b238351ca25352a848ad253d33e04e2c5adaf0..af4ca35a215f576e6ef2cf83a33a01421c88102d 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. - - - -[slackhook]: https://my.slack.com/services/new/incoming-webhook + \ 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 56f1ba7311e0b5bb53d8d8ebc8b9c14566011ba3..54e0ee611cb0fd2f4df19413ecd46af521c8aeb9 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.  -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/features/project/source/browse_files.feature b/features/project/source/browse_files.feature index d81bc9802bcf4b3ebaab7a975fe8f93b2c920b30..472ec9544f3024952977f83298d0df33c33f61fc 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 ecbd721c28161646aa4a426361f9f04c20118e05..fd583618dcfba7dfef55ec74ee33dbb5924a8cad 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 b4741f06d1bf695999ab1d51074c1392748e17b4..36fe21a047c5c5d0b89798bc1d7e06c69ce98b9d 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 0f0827f04772d1f446b5a72ff278b3a10757137c..abdbd795cd54c0e08ac49890310ddcda25ba4c39 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 875d27d9383c479d7a4161a2566c821846c8125a..6610b97ecb2492f235506d5e6413319fb7925df2 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/data_builder/push.rb b/lib/gitlab/data_builder/push.rb index d76aa38f74174b9c2c8ff3be95b2f01d2b9a3e6d..1ff34553f0ad78c9335b4cd92a1be4f6d0fd9c3c 100644 --- a/lib/gitlab/data_builder/push.rb +++ b/lib/gitlab/data_builder/push.rb @@ -41,7 +41,7 @@ module Gitlab type = Gitlab::Git.tag_ref?(ref) ? 'tag_push' : 'push' # Hash to be passed as post_receive_data - data = { + { object_kind: type, event_name: type, before: oldrev, @@ -61,16 +61,15 @@ module Gitlab repository: project.hook_attrs.slice(:name, :url, :description, :homepage, :git_http_url, :git_ssh_url, :visibility_level) } - - data end # This method provide a sample data generated with # existing project and commits to test webhooks def build_sample(project, user) - commits = project.repository.commits(project.default_branch, limit: 3) ref = "#{Gitlab::Git::BRANCH_REF_PREFIX}#{project.default_branch}" - build(project, user, commits.last.id, commits.first.id, ref, commits) + commits = project.repository.commits(project.default_branch.to_s, limit: 3) rescue [] + + build(project, user, commits.last&.id, commits.first&.id, ref, commits) end def checkout_sha(repository, newrev, ref) diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb index 98fd4e78126b744feb06dc79aeea28e498876885..e8bb9e1f805bb513df0c3887b296d1a6a5b45909 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/scripts/prepare_build.sh b/scripts/prepare_build.sh index de7379425cfe1aa78ea4dcc824f75a85dac32c90..fd173c0ba88dce6bcfae83cd38eb0b3210ca69df 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 fb4ccfa58c2ee5e737b2db0f9f777b81646723f3..22193eac6722fa52495fedbe4bd54ac2b11f2887 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/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb index 16365642a34b3c8b0edfbbd58bf0896a87d34804..2d892f4a2b7eacb0d612889f41a835de376266d9 100644 --- a/spec/controllers/projects/services_controller_spec.rb +++ b/spec/controllers/projects/services_controller_spec.rb @@ -8,6 +8,7 @@ describe Projects::ServicesController do before do sign_in(user) project.team << [user, :master] + controller.instance_variable_set(:@project, project) controller.instance_variable_set(:@service, service) end @@ -18,20 +19,60 @@ describe Projects::ServicesController do end describe "#test" do + context 'when can_test? returns false' do + it 'renders 404' do + allow_any_instance_of(Service).to receive(:can_test?).and_return(false) + + get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html + + expect(response).to have_http_status(404) + end + end + context 'success' do + context 'with empty project' do + let(:project) { create(:empty_project) } + + context 'with chat notification service' do + let(:service) { project.create_microsoft_teams_service(webhook: 'http://webhook.com') } + + it 'redirects and show success message' do + allow_any_instance_of(MicrosoftTeams::Notifier).to receive(:ping).and_return(true) + + get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html + + expect(response).to redirect_to(root_path) + expect(flash[:notice]).to eq('We sent a request to the provided URL') + end + end + + it 'redirects and show success message' do + expect(service).to receive(:test).and_return(success: true, result: 'done') + + get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html + + expect(response).to redirect_to(root_path) + expect(flash[:notice]).to eq('We sent a request to the provided URL') + end + end + it "redirects and show success message" do - expect(service).to receive(:test).and_return({ success: true, result: 'done' }) + expect(service).to receive(:test).and_return(success: true, result: 'done') + get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html - expect(response.status).to redirect_to('/') + + expect(response).to redirect_to(root_path) expect(flash[:notice]).to eq('We sent a request to the provided URL') end end context 'failure' do it "redirects and show failure message" do - expect(service).to receive(:test).and_return({ success: false, result: 'Bad test' }) + expect(service).to receive(:test).and_return(success: false, result: 'Bad test') + get :test, namespace_id: project.namespace.id, project_id: project.id, id: service.id, format: :html - expect(response.status).to redirect_to('/') + + expect(response).to redirect_to(root_path) expect(flash[:alert]).to eq('We tried to send a request to the provided URL but an error occurred: Bad test') end end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index b62def83ee43e84167d5433ea5d8fc5460b11321..78ddd8d5584817fff33045d3fb615be5b24f49fa 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/factories/services.rb b/spec/factories/services.rb index 88f6c2655053117ac8bd274ee78ae23c7cde6cf2..62aa71ae8d82ca19cbaf5b7d7e742543ee7ec90c 100644 --- a/spec/factories/services.rb +++ b/spec/factories/services.rb @@ -1,6 +1,19 @@ FactoryGirl.define do factory :service do project factory: :empty_project + type 'Service' + end + + factory :custom_issue_tracker_service, class: CustomIssueTrackerService do + project factory: :empty_project + type 'CustomIssueTrackerService' + category 'issue_tracker' + active true + properties( + project_url: 'https://project.url.com', + issues_url: 'https://issues.url.com', + new_issue_url: 'https://newissue.url.com' + ) end factory :kubernetes_service do diff --git a/spec/features/copy_as_gfm_spec.rb b/spec/features/copy_as_gfm_spec.rb index 344e31e5ef5c1e1eb1f3d23e4d980cc53dd3724a..f197fb446081c356abe57530d075f1dac207f85e 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 7cfa5b9716f90b4459812d9a56957614c53f5401..cc11cb7a55f6665a8be7080e572596abae779c60 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 d281043caa3c9e9c0cf369c295840d21ce0a6e97..70e96efd55704f9c4fcecb02acddd7ea5af61432 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/projects/milestones/milestone_spec.rb b/spec/features/projects/milestones/milestone_spec.rb index dab78fd35713238c7d20146c23652f2b1a4322e1..5e19907eef9047b2bb638dd1b0a5118c880381ab 100644 --- a/spec/features/projects/milestones/milestone_spec.rb +++ b/spec/features/projects/milestones/milestone_spec.rb @@ -63,4 +63,28 @@ feature 'Project milestone', :feature do expect(page).not_to have_content('Assign some issues to this milestone.') end end + + context 'when project has an issue' do + before do + create(:issue, project: project, milestone: milestone) + + visit namespace_project_milestone_path(project.namespace, project, milestone) + end + + describe 'the collapsed sidebar' do + before do + find('.milestone-sidebar .gutter-toggle').click + end + + it 'shows the total MR and issue counts' do + find('.milestone-sidebar .block', match: :first) + blocks = all('.milestone-sidebar .block') + + aggregate_failures 'MR and issue blocks' do + expect(blocks[3]).to have_content 1 + expect(blocks[4]).to have_content 0 + end + end + end + end end diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 6ecdc8cbb71e7c3401b331e28aa1a4f24cef0757..a1a36931824d87b7738a0b46f90f34f0f6435885 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 a8fc0624588d00dede2a2e4fba4910c06b464acc..5d58494a22aa6a33da0283d99c12bae8a4b060e1 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 c4d2f50ca1453db0c7145a4cf0836cbc4b1859da..5df5b710dc4083e403735339c689af1462e6d2ee 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 508aeb7cf67ec409aa8f2d1fac2ed9fe48b181cc..379f62f73e1f909f146d175a93bdfe42936d42f0 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 0000000000000000000000000000000000000000..13f122b68b2923306a27d2398423401f0e6f521b --- /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 0000000000000000000000000000000000000000..16490ad503984eff3c80ab75f9140be971c89b7e --- /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/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index 7a0b0b06d4b4a0c195d344255a676f37c29668c0..bfecfa28ed13d33e44112dff7ffefc65be1954c8 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -6980,28 +6980,6 @@ ], "services": [ - { - "id": 164, - "title": null, - "project_id": 5, - "created_at": "2016-06-14T15:02:07.372Z", - "updated_at": "2016-06-14T15:02:07.372Z", - "active": false, - "properties": { - - }, - "template": false, - "push_events": true, - "issues_events": true, - "merge_requests_events": true, - "tag_push_events": true, - "note_events": true, - "build_events": true, - "category": "issue_tracker", - "type": "CustomIssueTrackerService", - "default": true, - "wiki_page_events": true - }, { "id": 100, "title": "JetBrains TeamCity CI", @@ -7019,6 +6997,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "TeamcityService", "category": "ci", "default": false, "wiki_page_events": true @@ -7040,6 +7019,7 @@ "tag_push_events": true, "note_events": true, "pipeline_events": true, + "type": "SlackService", "category": "common", "default": false, "wiki_page_events": true @@ -7061,6 +7041,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "RedmineService", "category": "issue_tracker", "default": false, "wiki_page_events": true @@ -7082,6 +7063,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "PushoverService", "category": "common", "default": false, "wiki_page_events": true @@ -7103,6 +7085,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "PivotalTrackerService", "category": "common", "default": false, "wiki_page_events": true @@ -7125,6 +7108,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "JiraService", "category": "issue_tracker", "default": false, "wiki_page_events": true @@ -7146,6 +7130,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "IrkerService", "category": "common", "default": false, "wiki_page_events": true @@ -7167,6 +7152,7 @@ "tag_push_events": true, "note_events": true, "pipeline_events": true, + "type": "HipchatService", "category": "common", "default": false, "wiki_page_events": true @@ -7188,6 +7174,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "GemnasiumService", "category": "common", "default": false, "wiki_page_events": true @@ -7209,6 +7196,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "FlowdockService", "category": "common", "default": false, "wiki_page_events": true @@ -7230,6 +7218,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "ExternalWikiService", "category": "common", "default": false, "wiki_page_events": true @@ -7251,6 +7240,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "EmailsOnPushService", "category": "common", "default": false, "wiki_page_events": true @@ -7272,6 +7262,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "DroneCiService", "category": "ci", "default": false, "wiki_page_events": true @@ -7293,6 +7284,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "CustomIssueTrackerService", "category": "issue_tracker", "default": false, "wiki_page_events": true @@ -7314,6 +7306,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "CampfireService", "category": "common", "default": false, "wiki_page_events": true @@ -7335,6 +7328,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "BuildkiteService", "category": "ci", "default": false, "wiki_page_events": true @@ -7356,6 +7350,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "BambooService", "category": "ci", "default": false, "wiki_page_events": true @@ -7377,6 +7372,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "AssemblaService", "category": "common", "default": false, "wiki_page_events": true @@ -7398,6 +7394,7 @@ "tag_push_events": true, "note_events": true, "build_events": true, + "type": "AssemblaService", "category": "common", "default": false, "wiki_page_events": true diff --git a/spec/lib/gitlab/import_export/relation_factory_spec.rb b/spec/lib/gitlab/import_export/relation_factory_spec.rb index fcc23a75ca1f00f1c8ddcb72c5720895030405b3..06cd8ab87ed4f8cfcb15b3fd72bc8c19e9b4ff96 100644 --- a/spec/lib/gitlab/import_export/relation_factory_spec.rb +++ b/spec/lib/gitlab/import_export/relation_factory_spec.rb @@ -60,7 +60,7 @@ describe Gitlab::ImportExport::RelationFactory, lib: true do end context 'original service exists' do - let(:service_id) { Service.create(project: project).id } + let(:service_id) { create(:service, project: project).id } it 'does not have the original service_id' do expect(created_object.service_id).not_to eq(service_id) diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index e5dd57fc4bb8e1eacfbbe7cf6ce9fcd08ed0ea32..7e8a1c8add79c81a514b9156d340b5eed0d0a5e4 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 0000000000000000000000000000000000000000..a3e598de56d9819fe6a0a6f4b70f524d56af27fb --- /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/project_services/chat_notification_service_spec.rb b/spec/models/project_services/chat_notification_service_spec.rb index 592c90cda365b775a9fc238dc568dad86bc57c40..8fbe42248ae4eb1406376887d882e0cffc940a3c 100644 --- a/spec/models/project_services/chat_notification_service_spec.rb +++ b/spec/models/project_services/chat_notification_service_spec.rb @@ -11,10 +11,10 @@ describe ChatNotificationService, models: true do describe '#can_test?' do context 'with empty repository' do - it 'returns false' do + it 'returns true' do subject.project = create(:empty_project, :empty_repo) - expect(subject.can_test?).to be false + expect(subject.can_test?).to be true end end diff --git a/spec/models/project_services/issue_tracker_service_spec.rb b/spec/models/project_services/issue_tracker_service_spec.rb index fbe6f344a98d1cb99831578f9a2019f3206fc4b1..869b25b933b29c69e4fdff21118d4e9e8ad0204a 100644 --- a/spec/models/project_services/issue_tracker_service_spec.rb +++ b/spec/models/project_services/issue_tracker_service_spec.rb @@ -8,7 +8,7 @@ describe IssueTrackerService, models: true do let(:service) { RedmineService.new(project: project, active: true) } before do - create(:service, project: project, active: true, category: 'issue_tracker') + create(:custom_issue_tracker_service, project: project) end context 'when service is changed manually by user' do diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index 0e2f07e945ff94a62eb2f9808a3ebdfaf9ff63ce..134882648b9b49a1e01e216e30aa15269db3d540 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -6,44 +6,53 @@ describe Service, models: true do it { is_expected.to have_one :service_hook } end + describe 'Validations' do + it { is_expected.to validate_presence_of(:type) } + end + describe "Test Button" do - before do - @service = Service.new - end + describe '#can_test?' do + let(:service) { create(:service, project: project) } - describe "Testable" do - let(:project) { create(:project, :repository) } + context 'when repository is not empty' do + let(:project) { create(:project, :repository) } - before do - allow(@service).to receive(:project).and_return(project) - @testable = @service.can_test? + it 'returns true' do + expect(service.can_test?).to be true + end end - describe '#can_test?' do - it { expect(@testable).to eq(true) } + context 'when repository is empty' do + let(:project) { create(:empty_project) } + + it 'returns true' do + expect(service.can_test?).to be true + end end + end + + describe '#test' do + let(:data) { 'test' } + let(:service) { create(:service, project: project) } - describe '#test' do - let(:data) { 'test' } + context 'when repository is not empty' do + let(:project) { create(:project, :repository) } it 'test runs execute' do - expect(@service).to receive(:execute).with(data) + expect(service).to receive(:execute).with(data) - @service.test(data) + service.test(data) end end - end - describe "With commits" do - let(:project) { create(:project, :repository) } + context 'when repository is empty' do + let(:project) { create(:empty_project) } - before do - allow(@service).to receive(:project).and_return(project) - @testable = @service.can_test? - end + it 'test runs execute' do + expect(service).to receive(:execute).with(data) - describe '#can_test?' do - it { expect(@testable).to eq(true) } + service.test(data) + end end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 0a2860f25059a86fab6ff6a85631d7c90203965c..0bcebc275988b0bc43922e030615598fcdee4692 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/requests/api/v3/deployments_spec.rb b/spec/requests/api/v3/deployments_spec.rb index 694786c3046178bf5c2b4356c7783068734e30ad..0389a26478167fc7b88c8ab7f7595d31a374073e 100644 --- a/spec/requests/api/v3/deployments_spec.rb +++ b/spec/requests/api/v3/deployments_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe API::Deployments do +describe API::V3::Deployments do let(:user) { create(:user) } let(:non_member) { create(:user) } let(:project) { deployment.environment.project } @@ -24,11 +24,11 @@ describe API::Deployments do describe 'GET /projects/:id/deployments' do context 'as member of the project' do it_behaves_like 'a paginated resources' do - let(:request) { get api("/projects/#{project.id}/deployments", user) } + let(:request) { get v3_api("/projects/#{project.id}/deployments", user) } end it 'returns projects deployments' do - get api("/projects/#{project.id}/deployments", user) + get v3_api("/projects/#{project.id}/deployments", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -40,7 +40,7 @@ describe API::Deployments do context 'as non member' do it 'returns a 404 status code' do - get api("/projects/#{project.id}/deployments", non_member) + get v3_api("/projects/#{project.id}/deployments", non_member) expect(response).to have_http_status(404) end @@ -50,7 +50,7 @@ describe API::Deployments do describe 'GET /projects/:id/deployments/:deployment_id' do context 'as a member of the project' do it 'returns the projects deployment' do - get api("/projects/#{project.id}/deployments/#{deployment.id}", user) + get v3_api("/projects/#{project.id}/deployments/#{deployment.id}", user) expect(response).to have_http_status(200) expect(json_response['sha']).to match /\A\h{40}\z/ @@ -60,7 +60,7 @@ describe API::Deployments do context 'as non member' do it 'returns a 404 status code' do - get api("/projects/#{project.id}/deployments/#{deployment.id}", non_member) + get v3_api("/projects/#{project.id}/deployments/#{deployment.id}", non_member) expect(response).to have_http_status(404) end diff --git a/spec/support/helpers/fake_blob_helpers.rb b/spec/support/helpers/fake_blob_helpers.rb new file mode 100644 index 0000000000000000000000000000000000000000..b29af732ad320440c6b3b70f7ba418c05338ee5f --- /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 7d238850520d043ca53d1a495e8238b5da33d80f..3e4ca8b7ab02b01836bfa497eef86b4c19e02d15 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/views/projects/blob/_viewer.html.haml_spec.rb b/spec/views/projects/blob/_viewer.html.haml_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a4915264abe19048d0573dba42e97393a137d915 --- /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