diff options
26 files changed, 877 insertions, 224 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7f665f19132..44620d390ad 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -412,18 +412,6 @@ rake karma: paths: - coverage-javascript/ -bundler:audit: - stage: test - <<: *ruby-static-analysis - <<: *dedicated-runner - only: - - master@gitlab-org/gitlab-ce - - master@gitlab-org/gitlab-ee - - master@gitlab/gitlabhq - - master@gitlab/gitlab-ee - script: - - "bundle exec bundle-audit check --update --ignore CVE-2016-4658" - .migration-paths: &migration-paths stage: test <<: *dedicated-runner diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 0bdce52cc89..7e469153106 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -344,6 +344,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:artifacts:browse': new BuildArtifacts(); break; + case 'projects:artifacts:file': + new BlobViewer(); + break; case 'help:index': gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge')); break; diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 687a462a0d4..f1b99023c72 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -101,9 +101,17 @@ window.gl.GfmAutoComplete = { } } }, - setup: function(input) { + setup: function(input, enableMap = { + emojis: true, + members: true, + issues: true, + milestones: true, + mergeRequests: true, + labels: true + }) { // Add GFM auto-completion to all input fields, that accept GFM input. this.input = input || $('.js-gfm-input'); + this.enableMap = enableMap; this.setupLifecycle(); }, setupLifecycle() { @@ -115,7 +123,84 @@ window.gl.GfmAutoComplete = { $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); }); }, + setupAtWho: function($input) { + if (this.enableMap.emojis) this.setupEmoji($input); + if (this.enableMap.members) this.setupMembers($input); + if (this.enableMap.issues) this.setupIssues($input); + if (this.enableMap.milestones) this.setupMilestones($input); + if (this.enableMap.mergeRequests) this.setupMergeRequests($input); + if (this.enableMap.labels) this.setupLabels($input); + + // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms + $input.filter('[data-supports-slash-commands="true"]').atwho({ + at: '/', + alias: 'commands', + searchKey: 'search', + skipSpecialCharacterTest: true, + data: this.defaultLoadingData, + displayTpl: function(value) { + if (this.isLoading(value)) return this.Loading.template; + var tpl = '<li>/${name}'; + if (value.aliases.length > 0) { + tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; + } + if (value.params.length > 0) { + tpl += ' <small><%- params.join(" ") %></small>'; + } + if (value.description !== '') { + tpl += '<small class="description"><i><%- description %></i></small>'; + } + tpl += '</li>'; + return _.template(tpl)(value); + }.bind(this), + insertTpl: function(value) { + var tpl = "/${name} "; + var reference_prefix = null; + if (value.params.length > 0) { + reference_prefix = value.params[0][0]; + if (/^[@%~]/.test(reference_prefix)) { + tpl += '<%- reference_prefix %>'; + } + } + return _.template(tpl)({ reference_prefix: reference_prefix }); + }, + suffix: '', + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert, + beforeSave: function(commands) { + if (gl.GfmAutoComplete.isLoading(commands)) return commands; + return $.map(commands, function(c) { + var search = c.name; + if (c.aliases.length > 0) { + search = search + " " + c.aliases.join(" "); + } + return { + name: c.name, + aliases: c.aliases, + params: c.params, + description: c.description, + search: search + }; + }); + }, + matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { + var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; + var match = regexp.exec(subtext); + if (match) { + return match[1]; + } else { + return null; + } + } + } + }); + return; + }, + + setupEmoji($input) { // Emoji $input.atwho({ at: ':', @@ -139,6 +224,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMembers($input) { // Team Members $input.atwho({ at: '@', @@ -180,6 +268,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupIssues($input) { $input.atwho({ at: '#', alias: 'issues', @@ -208,6 +299,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMilestones($input) { $input.atwho({ at: '%', alias: 'milestones', @@ -236,6 +330,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupMergeRequests($input) { $input.atwho({ at: '!', alias: 'mergerequests', @@ -264,6 +361,9 @@ window.gl.GfmAutoComplete = { } } }); + }, + + setupLabels($input) { $input.atwho({ at: '~', alias: 'labels', @@ -298,73 +398,8 @@ window.gl.GfmAutoComplete = { } } }); - // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms - $input.filter('[data-supports-slash-commands="true"]').atwho({ - at: '/', - alias: 'commands', - searchKey: 'search', - skipSpecialCharacterTest: true, - data: this.defaultLoadingData, - displayTpl: function(value) { - if (this.isLoading(value)) return this.Loading.template; - var tpl = '<li>/${name}'; - if (value.aliases.length > 0) { - tpl += ' <small>(or /<%- aliases.join(", /") %>)</small>'; - } - if (value.params.length > 0) { - tpl += ' <small><%- params.join(" ") %></small>'; - } - if (value.description !== '') { - tpl += '<small class="description"><i><%- description %></i></small>'; - } - tpl += '</li>'; - return _.template(tpl)(value); - }.bind(this), - insertTpl: function(value) { - var tpl = "/${name} "; - var reference_prefix = null; - if (value.params.length > 0) { - reference_prefix = value.params[0][0]; - if (/^[@%~]/.test(reference_prefix)) { - tpl += '<%- reference_prefix %>'; - } - } - return _.template(tpl)({ reference_prefix: reference_prefix }); - }, - suffix: '', - callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - beforeSave: function(commands) { - if (gl.GfmAutoComplete.isLoading(commands)) return commands; - return $.map(commands, function(c) { - var search = c.name; - if (c.aliases.length > 0) { - search = search + " " + c.aliases.join(" "); - } - return { - name: c.name, - aliases: c.aliases, - params: c.params, - description: c.description, - search: search - }; - }); - }, - matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { - var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; - var match = regexp.exec(subtext); - if (match) { - return match[1]; - } else { - return null; - } - } - } - }); - return; }, + fetchData: function($input, at) { if (this.isLoadingData[at]) return; this.isLoadingData[at] = true; diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index a13588b4218..1224e9503c9 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -1,11 +1,13 @@ class Projects::ArtifactsController < Projects::ApplicationController include ExtractsPath + include RendersBlob layout 'project' before_action :authorize_read_build! before_action :authorize_update_build!, only: [:keep] before_action :extract_ref_name_and_path before_action :validate_artifacts! + before_action :set_path_and_entry, only: [:file, :raw] def download if artifacts_file.file_storage? @@ -24,15 +26,24 @@ class Projects::ArtifactsController < Projects::ApplicationController end def file - entry = build.artifacts_metadata_entry(params[:path]) + blob = @entry.blob + override_max_blob_size(blob) - if entry.exists? - send_artifacts_entry(build, entry) - else - render_404 + respond_to do |format| + format.html do + render 'file' + end + + format.json do + render_blob_json(blob) + end end end + def raw + send_artifacts_entry(build, @entry) + end + def keep build.keep_artifacts! redirect_to namespace_project_build_path(project.namespace, project, build) @@ -81,4 +92,11 @@ class Projects::ArtifactsController < Projects::ApplicationController def artifacts_file @artifacts_file ||= build.artifacts_file end + + def set_path_and_entry + @path = params[:path] + @entry = build.artifacts_metadata_entry(@path) + + render_404 unless @entry.exists? + end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 37b6f4ad5cc..af430270ae4 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -119,7 +119,9 @@ module BlobHelper end def blob_raw_url - if @snippet + if @build && @entry + raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path) + elsif @snippet if @snippet.project_id raw_namespace_project_snippet_path(@project.namespace, @project, @snippet) else @@ -250,6 +252,8 @@ module BlobHelper case viewer.blob.external_storage when :lfs 'it is stored in LFS' + when :build_artifact + 'it is stored as a job artifact' else 'it is stored externally' end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index e9b7cbbad6a..1336c676134 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -208,6 +208,8 @@ module GitlabRoutingHelper browse_namespace_project_build_artifacts_path(*args) when 'file' file_namespace_project_build_artifacts_path(*args) + when 'raw' + raw_namespace_project_build_artifacts_path(*args) end end diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb new file mode 100644 index 00000000000..b35febc9ac5 --- /dev/null +++ b/app/models/ci/artifact_blob.rb @@ -0,0 +1,35 @@ +module Ci + class ArtifactBlob + include BlobLike + + attr_reader :entry + + def initialize(entry) + @entry = entry + end + + delegate :name, :path, to: :entry + + def id + Digest::SHA1.hexdigest(path) + end + + def size + entry.metadata[:size] + end + + def data + "Build artifact #{path}" + end + + def mode + entry.metadata[:mode] + end + + def external_storage + :build_artifact + end + + alias_method :external_size, :size + end +end diff --git a/app/serializers/README.md b/app/serializers/README.md new file mode 100644 index 00000000000..0337f88db5f --- /dev/null +++ b/app/serializers/README.md @@ -0,0 +1,325 @@ +# Serializers + +This is a documentation for classes located in `app/serializers` directory. + +In GitLab, we use [grape-entities][grape-entity-project], accompanied by a +serializer, to convert a Ruby object to its JSON representation. + +Serializers are typically used in controllers to build a JSON response +that is usually consumed by a frontend code. + +## Why using a serializer is important? + +Using serializers, instead of `to_json` method, has several benefits: + +* it helps to prevent exposure of a sensitive data stored in the database +* it makes it easier to test what should and should not be exposed +* it makes it easier to reuse serialization entities that are building blocks +* it makes it easier to move complexity from controllers to easily testable + classes +* it encourages hiding complexity behind intentions-revealing interfaces +* it makes it easier to take care about serialization performance concerns +* it makes it easier to reduce merge conflicts between CE -> EE +* it makes it easier to benefit from domain driven development techniques + +## What is a serializer? + +A serializer is a class that encapsulates all business rules for building a +JSON response using serialization entities. + +It is designed to be testable and to support passing additional context from +the controller. + +## What is a serialization entity? + +Entities are lightweight structures that allow to represent domain models +in a consistent and abstracted way, and reuse them as building blocks to +create a payload. + +Entities located in `app/serializers` are usually derived from a +[`Grape::Entity`][grape-entity-class] class. + +Serialization entities that do require to have a knowledge about specific +elements of the request, need to mix `RequestAwareEntity` in. + +A serialization entity usually maps a domain model class into its JSON +representation. It rarely happens that a serialization entity exists without +a corresponding domain model class. As an example, we have an `Issue` class and +a corresponding `IssueSerializer`. + +Serialization entites are designed to reuse other serialization entities, which +is a convenient way to create a multi-level JSON representation of a piece of +a domain model you want to serialize. + +See [documentation for Grape Entites][grape-entity-readme] for more details. + +## How to implement a serializer? + +### Base implementation + +In order to effectively implement a serializer it is necessary to create a new +class in `app/serializers`. See existing serializers as an example. + +A new serializer should inherit from a `BaseSerializer` class. It is necessary +to specify which serialization entity will be used to serialize a resource. + +```ruby +class MyResourceSerializer < BaseSerialize + entity MyResourceEntity +end +``` + +The example above shows how a most simple serializer can look like. + +Given that the entity `MyResourceEntity` exists, you can now use +`MyResourceSerializer` in the controller by creating an instance of it, and +calling `MyResourceSerializer#represent(resource)` method. + +Note that a `resource` can be either a single object, an array of objects or an +`ActiveRecord::Relation` object. A serialization entity should be smart enough +to accurately represent each of these. + +It should not be necessary to use `Enumerable#map`, and it should be avoided +from the performance reasons. + +### Choosing what gets serialized + +It often happens that you might want to use the same serializer in many places, +but sometimes the intention is to only expose a small subset of object's +attributes in one place, and a different subset in another. + +`BaseSerializer#represent(resource, opts = {})` method can take an additional +hash argument, `opts`, that defines what is going to be serialized. + +`BaseSerializer` will pass these options to a serialization entity. See +how it is [documented in the upstream project][grape-entity-only]. + +With this approach you can extend the serializer to respond to methods that will +create a JSON response according to your needs. + +```ruby +class PipelineSerializer < BaseSerializer + entity PipelineEntity + + def represent_details(resource) + represent(resource, only: [:details]) + end + + def represent_status(resource) + represent(resource, only: [:status]) + end +end +``` + +It is possible to use `only` and `except` keywords. Both keywords do support +nested attributes, like `except: [:id, { user: [:id] }]`. + +Passing `only` and `except` to the `represent` method from a controller is +possible, but it defies principles of encapsulation and testability, and it is +better to avoid it, and to add a specific method to the serializer instead. + +### Reusing serialization entities from the API + +Public API in GitLab is implemented using [Grape][grape-project]. + +Under the hood it also uses [`Grape::Entity`][grape-entity-class] classes. +This means that it is possible to reuse these classes to implement internal +serializers. + +You can either use such entity directly: + +```ruby +class MyResourceSerializer < BaseSerializer + entity API::Entities::SomeEntity +end +``` + +Or derive a new serialization entity class from it: + +```ruby +class MyEntity < API::Entities::SomeEntity + include RequestAwareEntity + + unexpose :something +end +``` + +It might be a good idea to write specs for entities that do inherit from +the API, because when API payloads are changed / extended, it is easy to forget +about the impact on the internal API through a serializer that reuses API +entities. + +It is usually safe to do that, because API entities rarely break backward +compatibility, but additional exposure may have a performance impact when API +gets extended significantly. Write tests that check if only necessary data is +exposed. + +## How to write tests for a serializer? + +Like every other class in the project, creating a serializer warrants writing +tests for it. + +It is usually a good idea to test each public method in the serializer against +a valid payload. `BaseSerializer#represent` returns a hash, so it is possible +to use usual RSpec matchers like `include`. + +Sometimes, when the payload is large, it makes sense to validate it entirely +using `match_response_schema` matcher along with a new fixture that can be +stored in `spec/fixtures/api/schemas/`. This matcher is using a `json-schema` +gem, which is quite flexible, see a [documentation][json-schema-gem] for it. + +## How to use a serializer in a controller? + +Once a new serializer is implemented, it is possible to use it in a controller. + +Create an instance of the serializer and render the response. + +```ruby +def index + format.json do + render json: MyResourceSerializer + .new(current_user: @current_user) + .represent_details(@project.resources) + nd +end +``` + +If it is necessary to include additional information in the payload, it is +possible to extend what is going to be rendered, the usual way: + +```ruby +def index + format.json do + render json: { + resources: MyResourceSerializer + .new(current_user: @current_user) + .represent_details(@project.resources), + count: @project.resources.count + } + nd +end +``` + +Note that in these examples an additional context is being passed to the +serializer (`current_user: @current_user`). + +## How to pass an additional context from the controller? + +It is possible to pass an additional context from a controller to a +serializer and each serialization entity that is used in the process. + +Serialization entities that do require an additional context have +`RequestAwareEntity` concern mixed in. This piece of the code exposes a method +called `request` in every serialization entity that is instantiated during +serialization. + +An object returned by this method is an instance of `EntityRequest`, which +behaves like an `OpenStruct` object, with the difference that it will raise +an error if an unknown method is called. + +In other words, in the previous example, `request` method will return an +instance of `EntityRequest` that responds to `current_user` method. It will be +available in every serialization entity instantiated by `MyResourceSerializer`. + +`EntityRequest` is a workaround for [#20045][issue-20045] and is meant to be +refactored soon. Please avoid passing an additional context that is not +required by a serialization entity. + +At the moment, the context that is passed to entities most often is +`current_user` and `project`. + +## How is this related to using presenters? + +Payload created by a serializer is usually a representation of the backed code, +combined with the current request data. Therefore, technically, serializers +are presenters that create payload consumed by a frontend code, usually Vue +components. + +In GitLab, it is possible to use [presenters][presenters-readme], but +`BaseSerializer` still needs to learn how to use it, see [#30898][issue-30898]. + +It is possible to use presenters when serializer is used to represent only +a single object. It is not supported when `ActiveRecord::Relation` is being +serialized. + +```ruby +MyObjectSerializer.new.represent(object.present) +``` + +## Best practices + +1. Do not invoke a serializer from within a serialization entity. + + If you need to use a serializer from within a serialization entity, it is + possible that you are missing a class for an important domain concept. + + Consider creating a new domain class and a corresponding serialization + entity for it. + +1. Use only one approach to switch behavior of the serializer. + + It is possible to use a few approaches to switch a behavior of the + serializer. Most common are using a [Fluent Interface][fluent-interface] + and creating a separate `represent_something` methods. + + Whatever you choose, it might be better to use only one approach at a time. + +1. Do not forget about creating specs for serialization entities. + + Writing tests for the serializer indeed does cover testing a behavior of + serialization entities that the serializer instantiates. However it might + be a good idea to write separate tests for entities as well, because these + are meant to be reused in different serializers, and a serializer can + change a behavior of a serialization entity. + +1. Use `ActiveRecord::Relation` where possible + + Using an `ActiveRecord::Relation` might help from the performance perspective. + +1. Be diligent about passing an additional context from the controller. + + Using `EntityRequest` and `RequestAwareEntity` is a workaround for the lack + of high-level mechanism. It is meant to be refactored, and current + implementation is error prone. Imagine the situation that one serialization + entity requires `request.user` attribute, but the second one wants + `request.current_user`. When it happens that these two entities are used in + the same serialization request, you might need to pass both parameters to + the serializer, which is obviously not a perfect situation. + + When in doubt, pass only `current_user` and `project` if these are required. + +1. Keep performance concerns in mind + + Using a serializer incorrectly can have significant impact on the + performance. + + Because serializers are technically presenters, it is often necessary + to calculate, for example, paths to various controller-actions. + Since using URL helpers usually involve passing `project` and `namespace` + adding `includes(project: :namespace)` in the serializer, can help to avoid + N+1 queries. + + Also, try to avoid using `Enumerable#map` or other methods that will + execute a database query eagerly. + +1. Avoid passing `only` and `except` from the controller. +1. Write tests checking for N+1 queries. +1. Write controller tests for actions / formats using serializers. +1. Write tests that check if only necessary data is exposed. +1. Write tests that check if no sensitive data is exposed. + +## Future + +* [Next iteration of serializers][issue-27569] + +[grape-project]: http://www.ruby-grape.org +[grape-entity-project]: https://github.com/ruby-grape/grape-entity +[grape-entity-readme]: https://github.com/ruby-grape/grape-entity/blob/master/README.md +[grape-entity-class]: https://github.com/ruby-grape/grape-entity/blob/master/lib/grape_entity/entity.rb +[grape-entity-only]: https://github.com/ruby-grape/grape-entity/blob/master/README.md#returning-only-the-fields-you-want +[presenters-readme]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/presenters/README.md +[fluent-interface]: https://en.wikipedia.org/wiki/Fluent_interface +[json-schema-gem]: https://github.com/ruby-json-schema/json-schema +[issue-20045]: https://gitlab.com/gitlab-org/gitlab-ce/issues/20045 +[issue-30898]: https://gitlab.com/gitlab-org/gitlab-ce/issues/30898 +[issue-27569]: https://gitlab.com/gitlab-org/gitlab-ce/issues/27569 diff --git a/app/validators/dynamic_path_validator.rb b/app/validators/dynamic_path_validator.rb index 226eb6b313c..d992b0c3725 100644 --- a/app/validators/dynamic_path_validator.rb +++ b/app/validators/dynamic_path_validator.rb @@ -115,13 +115,20 @@ class DynamicPathValidator < ActiveModel::EachValidator # this would map to the activity-page of it's parent. GROUP_ROUTES = %w[ activity + analytics + audit_events avatar edit group_members + hooks issues labels + ldap + ldap_group_links merge_requests milestones + notification_setting + pipeline_quota projects subgroups ].freeze diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml index 36fb4c998c9..ce7e25d774b 100644 --- a/app/views/projects/artifacts/_tree_file.html.haml +++ b/app/views/projects/artifacts/_tree_file.html.haml @@ -1,9 +1,10 @@ - path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path) %tr.tree-item{ 'data-link' => path_to_file } + - blob = file.blob %td.tree-item-file-name - = tree_icon('file', '664', file.name) - %span.str-truncated - = link_to file.name, path_to_file + = tree_icon('file', blob.mode, blob.name) + = link_to path_to_file do + %span.str-truncated= blob.name %td - = number_to_human_size(file.metadata[:size], precision: 2) + = number_to_human_size(blob.size, precision: 2) diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml new file mode 100644 index 00000000000..d8da83b9a80 --- /dev/null +++ b/app/views/projects/artifacts/file.html.haml @@ -0,0 +1,33 @@ +- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' += render "projects/pipelines/head" + += render "projects/builds/header", show_controls: false + +#tree-holder.tree-holder + .nav-block + %ul.breadcrumb.repo-breadcrumb + %li + = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + - path_breadcrumbs do |title, path| + - title = truncate(title, length: 40) + %li + - if path == @path + = link_to file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) do + %strong= title + - else + = link_to title, browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + + + %article.file-holder + - blob = @entry.blob + .js-file-title.file-title-flex-parent + = render 'projects/blob/header_content', blob: blob + + .file-actions.hidden-xs + = render 'projects/blob/viewer_switcher', blob: blob + + .btn-group{ role: "group" }< + = copy_blob_source_button(blob) + = open_raw_blob_button(blob) + + = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index 718b52dd82e..d70ec8a6062 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -31,14 +31,14 @@ - if current_user - if note.emoji_awardable? - user_authored = note.user_authored?(current_user) - = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do + = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do = icon('pencil', class: 'link-highlight') - = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do + = link_to namespace_project_note_path(note.project.namespace, note.project, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do = icon('trash-o', class: 'danger-highlight') diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml index dace11e5474..679a5e934da 100644 --- a/app/views/snippets/notes/_actions.html.haml +++ b/app/views/snippets/notes/_actions.html.haml @@ -1,13 +1,13 @@ - if current_user - if note.emoji_awardable? - user_authored = note.user_authored?(current_user) - = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored}", data: { position: 'right' } do + = link_to '#', title: 'Award Emoji', class: "note-action-button note-emoji-button js-add-award js-note-emoji #{'js-user-authored' if user_authored} has-tooltip", data: { position: 'right' } do = icon('spinner spin') %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face') %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley') %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile') - if note_editable - = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do + = link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit has-tooltip' do = icon('pencil', class: 'link-highlight') - = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger' do + = link_to snippet_note_path(note.noteable, note), title: 'Remove comment', method: :delete, data: { confirm: 'Are you sure you want to remove this comment?' }, remote: true, class: 'note-action-button js-note-delete danger has-tooltip' do = icon('trash-o', class: 'danger-highlight') diff --git a/changelogs/unreleased/31760-add-tooltips-to-note-actions.yml b/changelogs/unreleased/31760-add-tooltips-to-note-actions.yml new file mode 100644 index 00000000000..9bbf43d652e --- /dev/null +++ b/changelogs/unreleased/31760-add-tooltips-to-note-actions.yml @@ -0,0 +1,4 @@ +--- +title: Add tooltips to note action buttons +merge_request: +author: diff --git a/changelogs/unreleased/dm-artifact-blob-viewer.yml b/changelogs/unreleased/dm-artifact-blob-viewer.yml new file mode 100644 index 00000000000..38f5cbb73e1 --- /dev/null +++ b/changelogs/unreleased/dm-artifact-blob-viewer.yml @@ -0,0 +1,4 @@ +--- +title: Add artifact file page that uses the blob viewer +merge_request: +author: diff --git a/config/routes/project.rb b/config/routes/project.rb index 956afe4faa3..085f5a24e2e 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -183,6 +183,7 @@ constraints(ProjectUrlConstrainer.new) do get :download get :browse, path: 'browse(/*path)', format: false get :file, path: 'file/*path', format: false + get :raw, path: 'raw/*path', format: false post :keep end end diff --git a/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb b/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb index a23f83205f1..08cf366f0a1 100644 --- a/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb +++ b/db/post_migrate/20170412174900_rename_reserved_dynamic_paths.rb @@ -36,10 +36,17 @@ class RenameReservedDynamicPaths < ActiveRecord::Migration DISSALLOWED_GROUP_PATHS = %w[ activity + analytics + audit_events avatar group_members + hooks labels + ldap + ldap_group_links milestones + notification_setting + pipeline_quota subgroups ] diff --git a/features/project/builds/artifacts.feature b/features/project/builds/artifacts.feature index 09094d638c9..5abc24949cf 100644 --- a/features/project/builds/artifacts.feature +++ b/features/project/builds/artifacts.feature @@ -46,13 +46,14 @@ Feature: Project Builds Artifacts And I navigate to parent directory of directory with invalid name Then I should not see directory with invalid name on the list + @javascript Scenario: I download a single file from build artifacts Given recent build has artifacts available And recent build has artifacts metadata available When I visit recent build details page And I click artifacts browse button And I click a link to file within build artifacts - Then download of a file extracted from build artifacts should start + Then I see a download link @javascript Scenario: I click on a row in an artifacts table diff --git a/features/steps/project/builds/artifacts.rb b/features/steps/project/builds/artifacts.rb index 3ec5c8e2f4f..eec375b0532 100644 --- a/features/steps/project/builds/artifacts.rb +++ b/features/steps/project/builds/artifacts.rb @@ -3,6 +3,7 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps include SharedProject include SharedBuilds include RepoHelpers + include WaitForAjax step 'I click artifacts download button' do click_link 'Download' @@ -78,19 +79,11 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps step 'I click a link to file within build artifacts' do page.within('.tree-table') { find_link('ci_artifacts.txt').click } + wait_for_ajax end - step 'download of a file extracted from build artifacts should start' do - send_data = response_headers[Gitlab::Workhorse::SEND_DATA_HEADER] - - expect(send_data).to start_with('artifacts-entry:') - - base64_params = send_data.sub(/\Aartifacts\-entry:/, '') - params = JSON.parse(Base64.urlsafe_decode64(base64_params)) - - expect(params.keys).to eq(%w(Archive Entry)) - expect(params['Archive']).to end_with('build_artifacts.zip') - expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt')) + step 'I see a download link' do + expect(page).to have_link 'download it' end step 'I click a first row within build artifacts table' do diff --git a/lib/gitlab/ci/build/artifacts/metadata/entry.rb b/lib/gitlab/ci/build/artifacts/metadata/entry.rb index 6f799c2f031..2e073334abc 100644 --- a/lib/gitlab/ci/build/artifacts/metadata/entry.rb +++ b/lib/gitlab/ci/build/artifacts/metadata/entry.rb @@ -37,6 +37,12 @@ module Gitlab !directory? end + def blob + return unless file? + + @blob ||= Blob.decorate(::Ci::ArtifactBlob.new(self), nil) + end + def has_parent? nodes > 0 end diff --git a/scripts/static-analysis b/scripts/static-analysis index 1bd6b339830..7dc8f679036 100755 --- a/scripts/static-analysis +++ b/scripts/static-analysis @@ -3,6 +3,7 @@ require ::File.expand_path('../lib/gitlab/popen', __dir__) tasks = [ + %w[bundle exec bundle-audit check --update --ignore CVE-2016-4658], %w[bundle exec rake config_lint], %w[bundle exec rake flay], %w[bundle exec rake haml_lint], diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb new file mode 100644 index 00000000000..eff9fab8da2 --- /dev/null +++ b/spec/controllers/projects/artifacts_controller_spec.rb @@ -0,0 +1,188 @@ +require 'spec_helper' + +describe Projects::ArtifactsController do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + let(:pipeline) do + create(:ci_pipeline, + project: project, + sha: project.commit.sha, + ref: project.default_branch, + status: 'success') + end + + let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } + + before do + project.team << [user, :developer] + + sign_in(user) + end + + describe 'GET download' do + it 'sends the artifacts file' do + expect(controller).to receive(:send_file).with(build.artifacts_file.path, disposition: 'attachment').and_call_original + + get :download, namespace_id: project.namespace, project_id: project, build_id: build + end + end + + describe 'GET browse' do + context 'when the directory exists' do + it 'renders the browse view' do + get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'other_artifacts_0.1.2' + + expect(response).to render_template('projects/artifacts/browse') + end + end + + context 'when the directory does not exist' do + it 'responds Not Found' do + get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET file' do + context 'when the file exists' do + it 'renders the file view' do + get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + + expect(response).to render_template('projects/artifacts/file') + end + end + + context 'when the file does not exist' do + it 'responds Not Found' do + get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET raw' do + context 'when the file exists' do + it 'serves the file using workhorse' do + get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + + send_data = response.headers[Gitlab::Workhorse::SEND_DATA_HEADER] + + expect(send_data).to start_with('artifacts-entry:') + + base64_params = send_data.sub(/\Aartifacts\-entry:/, '') + params = JSON.parse(Base64.urlsafe_decode64(base64_params)) + + expect(params.keys).to eq(%w(Archive Entry)) + expect(params['Archive']).to end_with('build_artifacts.zip') + expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt')) + end + end + + context 'when the file does not exist' do + it 'responds Not Found' do + get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET latest_succeeded' do + def params_from_ref(ref = pipeline.ref, job = build.name, path = 'browse') + { + namespace_id: project.namespace, + project_id: project, + ref_name_and_path: File.join(ref, path), + job: job + } + end + + context 'cannot find the build' do + shared_examples 'not found' do + it { expect(response).to have_http_status(:not_found) } + end + + context 'has no such ref' do + before do + get :latest_succeeded, params_from_ref('TAIL', build.name) + end + + it_behaves_like 'not found' + end + + context 'has no such build' do + before do + get :latest_succeeded, params_from_ref(pipeline.ref, 'NOBUILD') + end + + it_behaves_like 'not found' + end + + context 'has no path' do + before do + get :latest_succeeded, params_from_ref(pipeline.sha, build.name, '') + end + + it_behaves_like 'not found' + end + end + + context 'found the build and redirect' do + shared_examples 'redirect to the build' do + it 'redirects' do + path = browse_namespace_project_build_artifacts_path( + project.namespace, + project, + build) + + expect(response).to redirect_to(path) + end + end + + context 'with regular branch' do + before do + pipeline.update(ref: 'master', + sha: project.commit('master').sha) + + get :latest_succeeded, params_from_ref('master') + end + + it_behaves_like 'redirect to the build' + end + + context 'with branch name containing slash' do + before do + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + + get :latest_succeeded, params_from_ref('improve/awesome') + end + + it_behaves_like 'redirect to the build' + end + + context 'with branch name and path containing slashes' do + before do + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + + get :latest_succeeded, params_from_ref('improve/awesome', build.name, 'file/README.md') + end + + it 'redirects' do + path = file_namespace_project_build_artifacts_path( + project.namespace, + project, + build, + 'README.md') + + expect(response).to redirect_to(path) + end + end + end + end +end diff --git a/spec/features/projects/artifacts/file_spec.rb b/spec/features/projects/artifacts/file_spec.rb new file mode 100644 index 00000000000..74308a7e8dd --- /dev/null +++ b/spec/features/projects/artifacts/file_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +feature 'Artifact file', :js, feature: true do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } + let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } + + def visit_file(path) + visit file_namespace_project_build_artifacts_path(project.namespace, project, build, path) + end + + context 'Text file' do + before do + visit_file('other_artifacts_0.1.2/doc_sample.txt') + + wait_for_ajax + end + + it 'displays an error' do + aggregate_failures do + # shows an error message + expect(page).to have_content('The source could not be displayed because it is stored as a job artifact. You can download it instead.') + + # 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') + + # shows a download button + expect(page).to have_link('Download') + end + end + end + + context 'JPG file' do + before do + visit_file('rails_sample.jpg') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows rendered image + expect(page).to have_selector('.image_file img') + + # 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') + + # shows a download button + expect(page).to have_link('Download') + end + end + end +end diff --git a/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb b/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb index abc93e1b44a..3b905611467 100644 --- a/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb +++ b/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb @@ -135,6 +135,17 @@ describe Gitlab::Ci::Build::Artifacts::Metadata::Entry do subject { |example| path(example).nodes } it { is_expected.to eq 4 } end + + describe '#blob' do + let(:file_entry) { |example| path(example) } + subject { file_entry.blob } + + it 'returns a blob representing the entry data' do + expect(subject).to be_a(Blob) + expect(subject.path).to eq(file_entry.path) + expect(subject.size).to eq(file_entry.metadata[:size]) + end + end end describe 'non-existent/', path: 'non-existent/' do diff --git a/spec/models/ci/artifact_blob_spec.rb b/spec/models/ci/artifact_blob_spec.rb new file mode 100644 index 00000000000..968593d7e9b --- /dev/null +++ b/spec/models/ci/artifact_blob_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe Ci::ArtifactBlob, models: true do + let(:build) { create(:ci_build, :artifacts) } + let(:entry) { build.artifacts_metadata_entry('other_artifacts_0.1.2/another-subdirectory/banana_sample.gif') } + + subject { described_class.new(entry) } + + describe '#id' do + it 'returns a hash of the path' do + expect(subject.id).to eq(Digest::SHA1.hexdigest(entry.path)) + end + end + + describe '#name' do + it 'returns the entry name' do + expect(subject.name).to eq(entry.name) + end + end + + describe '#path' do + it 'returns the entry path' do + expect(subject.path).to eq(entry.path) + end + end + + describe '#size' do + it 'returns the entry size' do + expect(subject.size).to eq(entry.metadata[:size]) + end + end + + describe '#mode' do + it 'returns the entry mode' do + expect(subject.mode).to eq(entry.metadata[:mode]) + end + end + + describe '#external_storage' do + it 'returns :build_artifact' do + expect(subject.external_storage).to eq(:build_artifact) + end + end +end diff --git a/spec/requests/projects/artifacts_controller_spec.rb b/spec/requests/projects/artifacts_controller_spec.rb deleted file mode 100644 index d20866c0d44..00000000000 --- a/spec/requests/projects/artifacts_controller_spec.rb +++ /dev/null @@ -1,117 +0,0 @@ -require 'spec_helper' - -describe Projects::ArtifactsController do - let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - - let(:pipeline) do - create(:ci_pipeline, - project: project, - sha: project.commit.sha, - ref: project.default_branch, - status: 'success') - end - - let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } - - describe 'GET /:project/builds/artifacts/:ref_name/browse?job=name' do - before do - project.team << [user, :developer] - - login_as(user) - end - - def path_from_ref( - ref = pipeline.ref, job = build.name, path = 'browse') - latest_succeeded_namespace_project_artifacts_path( - project.namespace, - project, - [ref, path].join('/'), - job: job) - end - - context 'cannot find the build' do - shared_examples 'not found' do - it { expect(response).to have_http_status(:not_found) } - end - - context 'has no such ref' do - before do - get path_from_ref('TAIL', build.name) - end - - it_behaves_like 'not found' - end - - context 'has no such build' do - before do - get path_from_ref(pipeline.ref, 'NOBUILD') - end - - it_behaves_like 'not found' - end - - context 'has no path' do - before do - get path_from_ref(pipeline.sha, build.name, '') - end - - it_behaves_like 'not found' - end - end - - context 'found the build and redirect' do - shared_examples 'redirect to the build' do - it 'redirects' do - path = browse_namespace_project_build_artifacts_path( - project.namespace, - project, - build) - - expect(response).to redirect_to(path) - end - end - - context 'with regular branch' do - before do - pipeline.update(ref: 'master', - sha: project.commit('master').sha) - - get path_from_ref('master') - end - - it_behaves_like 'redirect to the build' - end - - context 'with branch name containing slash' do - before do - pipeline.update(ref: 'improve/awesome', - sha: project.commit('improve/awesome').sha) - - get path_from_ref('improve/awesome') - end - - it_behaves_like 'redirect to the build' - end - - context 'with branch name and path containing slashes' do - before do - pipeline.update(ref: 'improve/awesome', - sha: project.commit('improve/awesome').sha) - - get path_from_ref('improve/awesome', build.name, 'file/README.md') - end - - it 'redirects' do - path = file_namespace_project_build_artifacts_path( - project.namespace, - project, - build, - 'README.md') - - expect(response).to redirect_to(path) - end - end - end - end -end |