diff options
50 files changed, 1009 insertions, 363 deletions
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue deleted file mode 100644 index fc6d83bf96c..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue +++ /dev/null @@ -1,44 +0,0 @@ -<script> -import Icon from '~/vue_shared/components/icon.vue'; -import { GlButton } from '@gitlab/ui'; - -export default { - name: 'StageCardListItem', - components: { - Icon, - GlButton, - }, - props: { - isActive: { - type: Boolean, - required: true, - }, - canEdit: { - type: Boolean, - default: false, - required: false, - }, - }, -}; -</script> - -<template> - <div - :class="{ active: isActive }" - class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-color-default border-style-solid border-width-1px" - > - <slot></slot> - <div v-if="canEdit" class="dropdown"> - <gl-button - :title="__('More actions')" - class="more-actions-toggle btn btn-transparent p-0" - data-toggle="dropdown" - > - <icon class="icon" name="ellipsis_v" /> - </gl-button> - <ul class="more-actions-dropdown dropdown-menu dropdown-open-left"> - <slot name="dropdown-options"></slot> - </ul> - </div> - </div> -</template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue index 004d335f572..1b09fe1b370 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue @@ -1,11 +1,6 @@ <script> -import StageCardListItem from './stage_card_list_item.vue'; - export default { name: 'StageNavItem', - components: { - StageCardListItem, - }, props: { isDefaultStage: { type: Boolean, @@ -40,16 +35,16 @@ export default { hasValue() { return this.value && this.value.length > 0; }, - editable() { - return this.isUserAllowed && this.canEdit; - }, }, }; </script> <template> <li @click="$emit('select')"> - <stage-card-list-item :is-active="isActive" :can-edit="editable"> + <div + :class="{ active: isActive }" + class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-color-default border-style-solid border-width-1px" + > <div class="stage-nav-item-cell stage-name p-0" :class="{ 'font-weight-bold': isActive }"> {{ title }} </div> @@ -62,27 +57,6 @@ export default { <span class="not-available">{{ __('Not available') }}</span> </template> </div> - <template v-slot:dropdown-options> - <template v-if="isDefaultStage"> - <li> - <button type="button" class="btn-default btn-transparent"> - {{ __('Hide stage') }} - </button> - </li> - </template> - <template v-else> - <li> - <button type="button" class="btn-default btn-transparent"> - {{ __('Edit stage') }} - </button> - </li> - <li> - <button type="button" class="btn-danger danger"> - {{ __('Remove stage') }} - </button> - </li> - </template> - </template> - </stage-card-list-item> + </div> </li> </template> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 5cbe8d6247a..5a89efa4538 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -38,7 +38,14 @@ export default { path: this.currentPath.replace(/^\//, ''), }; }, - update: data => data.project.repository.tree.lastCommit, + update: data => { + const pipelines = data.project.repository.tree.lastCommit.pipelines.edges; + + return { + ...data.project.repository.tree.lastCommit, + pipeline: pipelines.length && pipelines[0].node, + }; + }, context: { isSingleRequest: true, }, @@ -61,7 +68,7 @@ export default { computed: { statusTitle() { return sprintf(s__('Commits|Commit: %{commitText}'), { - commitText: this.commit.latestPipeline.detailedStatus.text, + commitText: this.commit.pipeline.detailedStatus.text, }); }, isLoading() { @@ -127,14 +134,14 @@ export default { <div v-if="commit.signatureHtml" v-html="commit.signatureHtml"></div> <div class="ci-status-link"> <gl-link - v-if="commit.latestPipeline" + v-if="commit.pipeline" v-gl-tooltip.left - :href="commit.latestPipeline.detailedStatus.detailsPath" + :href="commit.pipeline.detailedStatus.detailsPath" :title="statusTitle" class="js-commit-pipeline" > <ci-icon - :status="commit.latestPipeline.detailedStatus" + :status="commit.pipeline.detailedStatus" :size="24" :aria-label="statusTitle" /> diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index 564be211c46..7f974838359 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -34,7 +34,7 @@ export default { </script> <template> - <article class="file-holder js-hide-on-navigation limited-width-container readme-holder"> + <article class="file-holder limited-width-container readme-holder"> <div class="file-title"> <i aria-hidden="true" class="fa fa-file-text-o fa-fw"></i> <gl-link :href="blob.webUrl"> diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql index 71c1bf12749..74ccdd79dd0 100644 --- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql @@ -14,13 +14,17 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { webUrl } signatureHtml - latestPipeline { - detailedStatus { - detailsPath - icon - tooltip - text - group + pipelines(ref: $ref, first: 1) { + edges { + node { + detailedStatus { + detailsPath + icon + tooltip + text + group + } + } } } } diff --git a/app/assets/javascripts/repository/utils/readme.js b/app/assets/javascripts/repository/utils/readme.js index b219b857c66..e43b2bdc33a 100644 --- a/app/assets/javascripts/repository/utils/readme.js +++ b/app/assets/javascripts/repository/utils/readme.js @@ -3,7 +3,11 @@ const ASCIIDOC_EXTENSIONS = ['adoc', 'ad', 'asciidoc']; const OTHER_EXTENSIONS = ['textile', 'rdoc', 'org', 'creole', 'wiki', 'mediawiki', 'rst']; const EXTENSIONS = [...MARKDOWN_EXTENSIONS, ...ASCIIDOC_EXTENSIONS, ...OTHER_EXTENSIONS]; const PLAIN_FILENAMES = ['readme', 'index']; -const FILE_REGEXP = new RegExp(`^(${PLAIN_FILENAMES.join('|')})`, 'i'); +const FILE_REGEXP = new RegExp( + `^(${PLAIN_FILENAMES.join('|')})(.(${EXTENSIONS.join('|')}))?$`, + 'i', +); +const PLAIN_FILE_REGEXP = new RegExp(`^(${PLAIN_FILENAMES.join('|')})`, 'i'); const EXTENSIONS_REGEXP = new RegExp(`.(${EXTENSIONS.join('|')})$`, 'i'); // eslint-disable-next-line import/prefer-default-export @@ -11,7 +15,7 @@ export const readmeFile = blobs => { const readMeFiles = blobs.filter(f => f.name.search(FILE_REGEXP) !== -1); const previewableReadme = readMeFiles.find(f => f.name.search(EXTENSIONS_REGEXP) !== -1); - const plainReadme = readMeFiles.find(f => f.name.search(FILE_REGEXP) !== -1); + const plainReadme = readMeFiles.find(f => f.name.search(PLAIN_FILE_REGEXP) !== -1); return previewableReadme || plainReadme; }; diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index c9a8de0b290..5aa00af8910 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -148,7 +148,7 @@ module IssuableCollections when 'Issue' common_attributes + [:project, project: :namespace] when 'MergeRequest' - common_attributes + [:target_project, source_project: :route, head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits] + common_attributes + [:target_project, :latest_merge_request_diff, source_project: :route, head_pipeline: :project, target_project: :namespace] end end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index 3c86f3108ab..8c9bf17f017 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -6,7 +6,7 @@ class Groups::BoardsController < Groups::ApplicationController before_action :assign_endpoint_vars before_action do - push_frontend_feature_flag(:multi_select_board) + push_frontend_feature_flag(:multi_select_board, default_enabled: true) end private diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 3b335fa4af4..db05da0bb7f 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -8,7 +8,7 @@ class Projects::BoardsController < Projects::ApplicationController before_action :authorize_read_board!, only: [:index, :show] before_action :assign_endpoint_vars before_action do - push_frontend_feature_flag(:multi_select_board) + push_frontend_feature_flag(:multi_select_board, default_enabled: true) end private diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 5b7eb57841c..85d6b377934 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -10,6 +10,14 @@ module Resolvers end end + def self.last + @last ||= Class.new(self) do + def resolve(**args) + super.last + end + end + end + def self.resolver_complexity(args, child_complexity:) complexity = 1 complexity += 1 if args[:sort] diff --git a/app/graphql/resolvers/commit_pipelines_resolver.rb b/app/graphql/resolvers/commit_pipelines_resolver.rb new file mode 100644 index 00000000000..92a83523593 --- /dev/null +++ b/app/graphql/resolvers/commit_pipelines_resolver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Resolvers + class CommitPipelinesResolver < BaseResolver + include ::ResolvesPipelines + + alias_method :commit, :object + + def resolve(**args) + resolve_pipelines(commit.project, args.merge!({ sha: commit.sha })) + end + end +end diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb index d5600881728..dcf4a2802c7 100644 --- a/app/graphql/types/commit_type.rb +++ b/app/graphql/types/commit_type.rb @@ -29,12 +29,16 @@ module Types field :author, type: Types::UserType, null: true, description: 'Author of the commit' + field :pipelines, Types::Ci::PipelineType.connection_type, + null: true, + description: 'Pipelines of the commit ordered latest first', + resolver: Resolvers::CommitPipelinesResolver + field :latest_pipeline, type: Types::Ci::PipelineType, null: true, description: "Latest pipeline of the commit", - resolve: -> (obj, ctx, args) do - Gitlab::Graphql::Loaders::PipelineForShaLoader.new(obj.project, obj.sha).find_last - end + deprecation_reason: 'use pipelines', + resolver: Resolvers::CommitPipelinesResolver.last end end diff --git a/app/models/group.rb b/app/models/group.rb index 7496fee0b51..8289d4f099c 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -451,6 +451,14 @@ class Group < Namespace false end + def export_file_exists? + export_file&.file + end + + def export_file + import_export_upload&.export_file + end + private def update_two_factor_requirement diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 5cf2ded114d..df516009397 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -212,8 +212,8 @@ class MergeRequest < ApplicationRecord scope :join_project, -> { joins(:target_project) } scope :references_project, -> { references(:target_project) } scope :with_api_entity_associations, -> { - preload(:assignees, :author, :unresolved_notes, :labels, :milestone, :timelogs, - latest_merge_request_diff: [:merge_request_diff_commits], + preload(:assignees, :author, :unresolved_notes, :labels, :milestone, + :timelogs, :latest_merge_request_diff, metrics: [:latest_closed_by, :merged_by], target_project: [:route, { namespace: :route }], source_project: [:route, { namespace: :route }]) @@ -396,14 +396,17 @@ class MergeRequest < ApplicationRecord end end - def commit_shas - if persisted? - merge_request_diff.commit_shas - elsif compare_commits - compare_commits.to_a.reverse.map(&:sha) - else - Array(diff_head_sha) - end + def commit_shas(limit: nil) + return merge_request_diff.commit_shas(limit: limit) if persisted? + + shas = + if compare_commits + compare_commits.to_a.reverse.map(&:sha) + else + Array(diff_head_sha) + end + + limit ? shas.take(limit) : shas end # Returns true if there are commits that match at least one commit SHA. @@ -913,7 +916,7 @@ class MergeRequest < ApplicationRecord def commit_notes # Fetch comments only from last 100 commits - commit_ids = commit_shas.take(100) + commit_ids = commit_shas(limit: 100) Note .user diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 735ad046f22..5fe97a13a42 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -218,7 +218,7 @@ class MergeRequestDiff < ApplicationRecord end def last_commit_sha - commit_shas.first + commit_shas(limit: 1).first end def first_commit @@ -247,8 +247,8 @@ class MergeRequestDiff < ApplicationRecord project.commit_by(oid: head_commit_sha) end - def commit_shas - merge_request_diff_commits.map(&:sha) + def commit_shas(limit: nil) + merge_request_diff_commits.limit(limit).pluck(:sha) end def commits_by_shas(shas) diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index 8fb335c3801..d15f0ae3228 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -21,7 +21,7 @@ - if current_user_menu?(:start_trial) %li %a.profile-link{ href: trials_link_url } - = s_("CurrentUser|Start a trial") + = s_("CurrentUser|Start a Gold trial") = emoji_icon('rocket') - if current_user_menu?(:settings) %li diff --git a/changelogs/unreleased/36141-update-start-a-trial-option-in-top-right-drop-down-to-include-gold.yml b/changelogs/unreleased/36141-update-start-a-trial-option-in-top-right-drop-down-to-include-gold.yml new file mode 100644 index 00000000000..6952e630e2d --- /dev/null +++ b/changelogs/unreleased/36141-update-start-a-trial-option-in-top-right-drop-down-to-include-gold.yml @@ -0,0 +1,5 @@ +--- +title: Update start a trial option in top right drop down to include Gold +merge_request: 19971 +author: +type: changed diff --git a/changelogs/unreleased/36213-update-codequality-to-12-5.yml b/changelogs/unreleased/36213-update-codequality-to-12-5.yml new file mode 100644 index 00000000000..5b4429af81e --- /dev/null +++ b/changelogs/unreleased/36213-update-codequality-to-12-5.yml @@ -0,0 +1,5 @@ +--- +title: Update registry.gitlab.com/gitlab-org/security-products/codequality to 12-5-stable +merge_request: 20046 +author: Takuya Noguchi +type: other diff --git a/changelogs/unreleased/dz-move-project-routes.yml b/changelogs/unreleased/dz-move-project-routes.yml new file mode 100644 index 00000000000..713f6d90f32 --- /dev/null +++ b/changelogs/unreleased/dz-move-project-routes.yml @@ -0,0 +1,5 @@ +--- +title: Move some project routes under - scope +merge_request: 19954 +author: +type: deprecated diff --git a/changelogs/unreleased/ff-user-ids-per-scope-fe.yml b/changelogs/unreleased/ff-user-ids-per-scope-fe.yml deleted file mode 100644 index 2d928ce8c8a..00000000000 --- a/changelogs/unreleased/ff-user-ids-per-scope-fe.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Make User IDs work per scope in Feature Flags -merge_request: 19399 -author: -type: added diff --git a/changelogs/unreleased/georgekoltsov-group-structure-export-api-endp.yml b/changelogs/unreleased/georgekoltsov-group-structure-export-api-endp.yml new file mode 100644 index 00000000000..ed14e958b7f --- /dev/null +++ b/changelogs/unreleased/georgekoltsov-group-structure-export-api-endp.yml @@ -0,0 +1,5 @@ +--- +title: Add API endpoint to trigger Group Structure Export +merge_request: 19779 +author: +type: added diff --git a/changelogs/unreleased/id-avoid-preloading-merge-request-commits.yml b/changelogs/unreleased/id-avoid-preloading-merge-request-commits.yml new file mode 100644 index 00000000000..e937b8f2e6e --- /dev/null +++ b/changelogs/unreleased/id-avoid-preloading-merge-request-commits.yml @@ -0,0 +1,5 @@ +--- +title: Execute limited request for diff commits instead of preloading +merge_request: 19485 +author: +type: performance diff --git a/config/routes/project.rb b/config/routes/project.rb index dacc433784d..3f913683b00 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -191,6 +191,31 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do get 'proxy/:datasource_id/*proxy_path', to: 'grafana_api#proxy' get :metrics_dashboard, to: 'grafana_api#metrics_dashboard' end + + resource :mattermost, only: [:new, :create] + resource :variables, only: [:show, :update] + resources :triggers, only: [:index, :create, :edit, :update, :destroy] + + resource :mirror, only: [:show, :update] do + member do + get :ssh_host_keys, constraints: { format: :json } + post :update_now + end + end + + resource :cycle_analytics, only: [:show] + + namespace :cycle_analytics do + scope :events, controller: 'events' do + get :issue + get :plan + get :code + get :test + get :review + get :staging + get :production + end + end end # End of the /-/ scope. @@ -235,8 +260,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end - resource :mattermost, only: [:new, :create] - namespace :prometheus do resources :metrics, constraints: { id: %r{[^\/]+} }, only: [:index, :new, :create, :edit, :update, :destroy] do get :active_common, on: :collection @@ -364,17 +387,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do put '/service_desk' => 'service_desk#update', as: :service_desk_refresh end - resource :variables, only: [:show, :update] - - resources :triggers, only: [:index, :create, :edit, :update, :destroy] - - resource :mirror, only: [:show, :update] do - member do - get :ssh_host_keys, constraints: { format: :json } - post :update_now - end - end - Gitlab.ee do resources :push_rules, constraints: { id: /\d+/ }, only: [:update] end @@ -463,20 +475,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end - resource :cycle_analytics, only: [:show] - - namespace :cycle_analytics do - scope :events, controller: 'events' do - get :issue - get :plan - get :code - get :test - get :review - get :staging - get :production - end - end - namespace :serverless do scope :functions do get '/:environment_id/:id', to: 'functions#show' @@ -678,7 +676,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do :network, :graphs, :autocomplete_sources, :project_members, :deploy_keys, :deploy_tokens, :labels, :milestones, :services, :boards, :releases, - :forks, :group_links, :import, :avatar) + :forks, :group_links, :import, :avatar, :mirror, + :cycle_analytics, :mattermost, :variables, :triggers) end end end diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 0e397ed4f78..c75d9f236f9 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -139,7 +139,22 @@ type Commit { """ Latest pipeline of the commit """ - latestPipeline: Pipeline + latestPipeline( + """ + Filter pipelines by the ref they are run for + """ + ref: String + + """ + Filter pipelines by the sha of the commit they are run for + """ + sha: String + + """ + Filter pipelines by their status + """ + status: PipelineStatusEnum + ): Pipeline @deprecated(reason: "use pipelines") """ Raw commit message @@ -147,6 +162,46 @@ type Commit { message: String """ + Pipelines of the commit ordered latest first + """ + pipelines( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Filter pipelines by the ref they are run for + """ + ref: String + + """ + Filter pipelines by the sha of the commit they are run for + """ + sha: String + + """ + Filter pipelines by their status + """ + status: PipelineStatusEnum + ): PipelineConnection + + """ SHA1 ID of the commit """ sha: String! diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 384f641af62..629c18629cf 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -10210,15 +10210,44 @@ "name": "latestPipeline", "description": "Latest pipeline of the commit", "args": [ - + { + "name": "status", + "description": "Filter pipelines by their status", + "type": { + "kind": "ENUM", + "name": "PipelineStatusEnum", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "ref", + "description": "Filter pipelines by the ref they are run for", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "sha", + "description": "Filter pipelines by the sha of the commit they are run for", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } ], "type": { "kind": "OBJECT", "name": "Pipeline", "ofType": null }, - "isDeprecated": false, - "deprecationReason": null + "isDeprecated": true, + "deprecationReason": "use pipelines" }, { "name": "message", @@ -10235,6 +10264,89 @@ "deprecationReason": null }, { + "name": "pipelines", + "description": "Pipelines of the commit ordered latest first", + "args": [ + { + "name": "status", + "description": "Filter pipelines by their status", + "type": { + "kind": "ENUM", + "name": "PipelineStatusEnum", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "ref", + "description": "Filter pipelines by the ref they are run for", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "sha", + "description": "Filter pipelines by the sha of the commit they are run for", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PipelineConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "sha", "description": "SHA1 ID of the commit", "args": [ @@ -10308,6 +10420,118 @@ }, { "kind": "OBJECT", + "name": "PipelineConnection", + "description": "The connection type for Pipeline.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PipelineEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Pipeline", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PipelineEdge", + "description": "An edge in a connection.", + "fields": [ + { + "name": "cursor", + "description": "A cursor for use in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "The item at the end of the edge.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Pipeline", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", "name": "Pipeline", "description": null, "fields": [ @@ -13207,118 +13431,6 @@ }, { "kind": "OBJECT", - "name": "PipelineConnection", - "description": "The connection type for Pipeline.", - "fields": [ - { - "name": "edges", - "description": "A list of edges.", - "args": [ - - ], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PipelineEdge", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "nodes", - "description": "A list of nodes.", - "args": [ - - ], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Pipeline", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "pageInfo", - "description": "Information to aid in pagination.", - "args": [ - - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "PageInfo", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", - "name": "PipelineEdge", - "description": "An edge in a connection.", - "fields": [ - { - "name": "cursor", - "description": "A cursor for use in pagination.", - "args": [ - - ], - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "String", - "ofType": null - } - }, - "isDeprecated": false, - "deprecationReason": null - }, - { - "name": "node", - "description": "The item at the end of the edge.", - "args": [ - - ], - "type": { - "kind": "OBJECT", - "name": "Pipeline", - "ofType": null - }, - "isDeprecated": false, - "deprecationReason": null - } - ], - "inputFields": null, - "interfaces": [ - - ], - "enumValues": null, - "possibleTypes": null - }, - { - "kind": "OBJECT", "name": "IssueConnection", "description": "The connection type for Issue.", "fields": [ diff --git a/doc/user/application_security/container_scanning/index.md b/doc/user/application_security/container_scanning/index.md index c5aae41b587..931755c6305 100644 --- a/doc/user/application_security/container_scanning/index.md +++ b/doc/user/application_security/container_scanning/index.md @@ -185,19 +185,40 @@ Container Scanning can be executed on an offline air-gapped GitLab Ultimate inst 1. Host the following Docker images on a [local Docker container registry](../../packages/container_registry/index.md): - [arminc/clair-db vulnerabilities database](https://hub.docker.com/r/arminc/clair-db) - [GitLab klar analyzer](https://gitlab.com/gitlab-org/security-products/analyzers/klar) -1. [Override the container scanning template](#overriding-the-container-scanning-template) in your `.gitlab-ci.yml` file to refer to the Docker - images hosted on your local Docker container registry: +1. [Override the container scanning template](#overriding-the-container-scanning-template) in your `.gitlab-ci.yml` file to refer to the Docker images hosted on your local Docker container registry: ```yaml include: - template: Container-Scanning.gitlab-ci.yml container_scanning: - image: your.local.registry:5000/gitlab-klar-analyzer + image: $CI_REGISTRY/namespace/gitlab-klar-analyzer variables: - CLAIR_DB_IMAGE: your.local.registry:5000/clair-vulnerabilities-db + CLAIR_DB_IMAGE: $CI_REGISTRY/namespace/clair-vulnerabilities-db ``` +It may be worthwhile to set up a [scheduled pipeline](../../project/pipelines/schedules.md) to automatically build a new version of the vulnerabilities database on a preset schedule. You can use the following `.gitlab-yml.ci` as a template: + +```yaml +image: docker:stable + +services: + - docker:stable-dind + +stages: + - build + +build_latest_vulnerabilities: + stage: build + script: + - docker pull arminc/clair-db:latest + - docker tag arminc/clair-db:latest $CI_REGISTRY/namespace/clair-vulnerabilities-db + - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY + - docker push $CI_REGISTRY/namespace/clair-vulnerabilities-db +``` + +The above template will work for a GitLab Docker registry running on a local installation, however, if you're using a non-GitLab Docker registry, you'll need to change the `$CI_REGISTRY` value and the `docker login` credentials to match the details of your local registry. + ## Troubleshooting ### docker: Error response from daemon: failed to copy xattrs diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md index be6371bb1ca..26db2133d09 100644 --- a/doc/user/project/clusters/serverless/index.md +++ b/doc/user/project/clusters/serverless/index.md @@ -313,6 +313,41 @@ The sample function can now be triggered from any HTTP client using a simple `PO ![function execution](img/function-execution.png) +### Running functions locally + +Running a function locally is a good way to quickly verify behavior during development. + +Running functions locally requires: + +- Go 1.12 or newer installed. +- Docker Engine installed and running. +- `gitlabktl` installed using the Go package manager: + + ```shell + GO111MODULE=on go get gitlab.com/gitlab-org/gitlabktl + ``` + +To run a function locally: + +1. Navigate to the root of your GitLab serverless project. +1. Build your function into a Docker image: + + ```shell + gitlabktl serverless build + ``` + +1. Run your function in Docker: + + ```shell + docker run -itp 8080:8080 <your_function_name> + ``` + +1. Invoke your function: + + ```shell + curl http://localhost:8080 + ``` + ## Deploying Serverless applications > Introduced in GitLab 11.5. diff --git a/lib/api/api.rb b/lib/api/api.rb index 0062759d993..a2bdb76b834 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -113,6 +113,7 @@ module API mount ::API::Files mount ::API::GroupBoards mount ::API::GroupClusters + mount ::API::GroupExport mount ::API::GroupLabels mount ::API::GroupMilestones mount ::API::Groups diff --git a/lib/api/group_export.rb b/lib/api/group_export.rb new file mode 100644 index 00000000000..8025a16e191 --- /dev/null +++ b/lib/api/group_export.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module API + class GroupExport < Grape::API + before do + authorize! :admin_group, user_group + end + + params do + requires :id, type: String, desc: 'The ID of a group' + end + resource :groups, requirements: { id: %r{[^/]+} } do + desc 'Download export' do + detail 'This feature was introduced in GitLab 12.5.' + end + get ':id/export/download' do + if user_group.export_file_exists? + present_carrierwave_file!(user_group.export_file) + else + render_api_error!('404 Not found or has expired', 404) + end + end + + desc 'Start export' do + detail 'This feature was introduced in GitLab 12.5.' + end + post ':id/export' do + GroupExportWorker.perform_async(current_user.id, user_group.id, params) + + accepted! + end + end + end +end diff --git a/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml index 3cdb7b5420c..a60b00b2ee8 100644 --- a/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml @@ -18,7 +18,7 @@ code_quality: --env SOURCE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock - "registry.gitlab.com/gitlab-org/security-products/codequality:12-0-stable" /code + "registry.gitlab.com/gitlab-org/security-products/codequality:12-5-stable" /code artifacts: reports: codequality: gl-code-quality-report.json diff --git a/lib/gitlab/graphql/loaders/pipeline_for_sha_loader.rb b/lib/gitlab/graphql/loaders/pipeline_for_sha_loader.rb deleted file mode 100644 index 70344392138..00000000000 --- a/lib/gitlab/graphql/loaders/pipeline_for_sha_loader.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Graphql - module Loaders - class PipelineForShaLoader - attr_accessor :project, :sha - - def initialize(project, sha) - @project, @sha = project, sha - end - - def find_last - BatchLoader::GraphQL.for(sha).batch(key: project) do |shas, loader, args| - pipelines = args[:key].ci_pipelines.latest_for_shas(shas) - - pipelines.each do |pipeline| - loader.call(pipeline.sha, pipeline) - end - end - end - end - end - end -end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c8ec6da91ec..bcb56879f14 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5000,7 +5000,7 @@ msgstr "" msgid "CurrentUser|Settings" msgstr "" -msgid "CurrentUser|Start a trial" +msgid "CurrentUser|Start a Gold trial" msgstr "" msgid "Custom CI configuration path" @@ -7306,9 +7306,6 @@ msgstr "" msgid "FeatureFlags|Inactive flag for %{scope}" msgstr "" -msgid "FeatureFlags|Include additional user IDs" -msgstr "" - msgid "FeatureFlags|Install a %{docs_link_anchored_start}compatible client library%{docs_link_anchored_end} and specify the API URL, application name, and instance ID during the configuration setup. %{docs_link_start}More Information%{docs_link_end}" msgstr "" diff --git a/spec/features/projects/files/user_reads_pipeline_status_spec.rb b/spec/features/projects/files/user_reads_pipeline_status_spec.rb index 15f8fa7438d..9d38c44b6ef 100644 --- a/spec/features/projects/files/user_reads_pipeline_status_spec.rb +++ b/spec/features/projects/files/user_reads_pipeline_status_spec.rb @@ -9,8 +9,6 @@ describe 'user reads pipeline status', :js do let(:x110_pipeline) { create_pipeline('x1.1.0', 'failed') } before do - stub_feature_flags(vue_file_list: false) - project.add_maintainer(user) project.repository.add_tag(user, 'x1.1.0', 'v1.1.0') @@ -25,7 +23,7 @@ describe 'user reads pipeline status', :js do visit project_tree_path(project, expected_pipeline.ref) wait_for_requests - page.within('.blob-commit-info') do + page.within('.commit-detail') do expect(page).to have_link('', href: project_pipeline_path(project, expected_pipeline)) expect(page).to have_selector(".ci-status-icon-#{expected_pipeline.status}") end diff --git a/spec/frontend/cycle_analytics/stage_nav_item_spec.js b/spec/frontend/cycle_analytics/stage_nav_item_spec.js index ff079082ca7..a7a1d563e1e 100644 --- a/spec/frontend/cycle_analytics/stage_nav_item_spec.js +++ b/spec/frontend/cycle_analytics/stage_nav_item_spec.js @@ -133,45 +133,19 @@ describe('StageNavItem', () => { hasStageName(); }); - it('renders options menu', () => { - expect(wrapper.find('.more-actions-toggle').exists()).toBe(true); + it('does not render options menu', () => { + expect(wrapper.find('.more-actions-toggle').exists()).toBe(false); }); - describe('Default stages', () => { - beforeEach(() => { - wrapper = createComponent( - { canEdit: true, isUserAllowed: true, isDefaultStage: true }, - false, - ); - }); - it('can hide the stage', () => { - expect(wrapper.text()).toContain('Hide stage'); - }); - it('can not edit the stage', () => { - expect(wrapper.text()).not.toContain('Edit stage'); - }); - it('can not remove the stage', () => { - expect(wrapper.text()).not.toContain('Remove stage'); - }); + it('can not edit the stage', () => { + expect(wrapper.text()).not.toContain('Edit stage'); + }); + it('can not remove the stage', () => { + expect(wrapper.text()).not.toContain('Remove stage'); }); - describe('Custom stages', () => { - beforeEach(() => { - wrapper = createComponent( - { canEdit: true, isUserAllowed: true, isDefaultStage: false }, - false, - ); - }); - it('can edit the stage', () => { - expect(wrapper.text()).toContain('Edit stage'); - }); - it('can remove the stage', () => { - expect(wrapper.text()).toContain('Remove stage'); - }); - - it('can not hide the stage', () => { - expect(wrapper.text()).not.toContain('Hide stage'); - }); + it('can not hide the stage', () => { + expect(wrapper.text()).not.toContain('Hide stage'); }); }); }); diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index 01b56d453e6..e07ad4cf46b 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -17,7 +17,7 @@ function createCommitData(data = {}) { avatarUrl: 'https://test.com', webUrl: 'https://test.com/test', }, - latestPipeline: { + pipeline: { detailedStatus: { detailsPath: 'https://test.com/pipeline', icon: 'failed', @@ -74,7 +74,7 @@ describe('Repository last commit component', () => { }); it('hides pipeline components when pipeline does not exist', () => { - factory(createCommitData({ latestPipeline: null })); + factory(createCommitData({ pipeline: null })); expect(vm.find('.js-commit-pipeline').exists()).toBe(false); }); diff --git a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap index 3d5ec3fd411..a5e3eb4bce1 100644 --- a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap +++ b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap @@ -2,7 +2,7 @@ exports[`Repository file preview component renders file HTML 1`] = ` <article - class="file-holder js-hide-on-navigation limited-width-container readme-holder" + class="file-holder limited-width-container readme-holder" > <div class="file-title" diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index 954c4791c04..148e307a5d4 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -28,7 +28,7 @@ describe('Repository table component', () => { it('renders file preview', () => { factory('/'); - vm.setData({ entries: { blobs: [{ name: 'README.md ' }] } }); + vm.setData({ entries: { blobs: [{ name: 'README.md' }] } }); expect(vm.find(FilePreview).exists()).toBe(true); }); diff --git a/spec/frontend/repository/utils/readme_spec.js b/spec/frontend/repository/utils/readme_spec.js new file mode 100644 index 00000000000..6b7876c8947 --- /dev/null +++ b/spec/frontend/repository/utils/readme_spec.js @@ -0,0 +1,33 @@ +import { readmeFile } from '~/repository/utils/readme'; + +describe('readmeFile', () => { + describe('markdown files', () => { + it('returns markdown file', () => { + expect(readmeFile([{ name: 'README' }, { name: 'README.md' }])).toEqual({ + name: 'README.md', + }); + + expect(readmeFile([{ name: 'README' }, { name: 'index.md' }])).toEqual({ + name: 'index.md', + }); + }); + }); + + describe('plain files', () => { + it('returns plain file', () => { + expect(readmeFile([{ name: 'README' }, { name: 'TEST.md' }])).toEqual({ + name: 'README', + }); + + expect(readmeFile([{ name: 'readme' }, { name: 'TEST.md' }])).toEqual({ + name: 'readme', + }); + }); + }); + + describe('non-previewable file', () => { + it('returns undefined', () => { + expect(readmeFile([{ name: 'index.js' }, { name: 'TEST.md' }])).toBe(undefined); + }); + }); +}); diff --git a/spec/graphql/resolvers/base_resolver_spec.rb b/spec/graphql/resolvers/base_resolver_spec.rb index c162fdbbb47..a212bd07f35 100644 --- a/spec/graphql/resolvers/base_resolver_spec.rb +++ b/spec/graphql/resolvers/base_resolver_spec.rb @@ -13,6 +13,14 @@ describe Resolvers::BaseResolver do end end + let(:last_resolver) do + Class.new(described_class) do + def resolve(**args) + [1, 2] + end + end + end + describe '.single' do it 'returns a subclass from the resolver' do expect(resolver.single.superclass).to eq(resolver) @@ -29,6 +37,22 @@ describe Resolvers::BaseResolver do end end + describe '.last' do + it 'returns a subclass from the resolver' do + expect(last_resolver.last.superclass).to eq(last_resolver) + end + + it 'returns the same subclass every time' do + expect(last_resolver.last.object_id).to eq(last_resolver.last.object_id) + end + + it 'returns a resolver that gives the last result from the original resolver' do + result = resolve(last_resolver.last) + + expect(result).to eq(2) + end + end + context 'when field is a connection' do it 'increases complexity based on arguments' do field = Types::BaseField.new(name: 'test', type: GraphQL::STRING_TYPE.connection_type, resolver_class: described_class, null: false, max_page_size: 1) diff --git a/spec/graphql/resolvers/commit_pipelines_resolver_spec.rb b/spec/graphql/resolvers/commit_pipelines_resolver_spec.rb new file mode 100644 index 00000000000..93da877d714 --- /dev/null +++ b/spec/graphql/resolvers/commit_pipelines_resolver_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::CommitPipelinesResolver do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let(:commit) { create(:commit, project: project) } + let_it_be(:current_user) { create(:user) } + + let!(:pipeline) do + create( + :ci_pipeline, + project: project, + sha: commit.id, + ref: 'master', + status: 'success' + ) + end + let!(:pipeline2) do + create( + :ci_pipeline, + project: project, + sha: commit.id, + ref: 'master', + status: 'failed' + ) + end + let!(:pipeline3) do + create( + :ci_pipeline, + project: project, + sha: commit.id, + ref: 'my_branch', + status: 'failed' + ) + end + + before do + commit.project.add_developer(current_user) + end + + def resolve_pipelines + resolve(described_class, obj: commit, ctx: { current_user: current_user }, args: { ref: 'master' }) + end + + it 'resolves pipelines for commit and ref' do + pipelines = resolve_pipelines + + expect(pipelines).to eq([pipeline2, pipeline]) + end +end diff --git a/spec/graphql/types/commit_type_spec.rb b/spec/graphql/types/commit_type_spec.rb index 1ff1c97f8db..ee9af886e60 100644 --- a/spec/graphql/types/commit_type_spec.rb +++ b/spec/graphql/types/commit_type_spec.rb @@ -10,7 +10,7 @@ describe GitlabSchema.types['Commit'] do it 'contains attributes related to commit' do expect(described_class).to have_graphql_fields( :id, :sha, :title, :description, :message, :authored_date, - :author, :web_url, :latest_pipeline, :signature_html + :author, :web_url, :latest_pipeline, :pipelines, :signature_html ) end end diff --git a/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js b/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js index b2fe315f6c6..b53e30b6896 100644 --- a/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js +++ b/spec/javascripts/ci_variable_list/ajax_variable_list_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import AjaxFormVariableList from '~/ci_variable_list/ajax_variable_list'; -const VARIABLE_PATCH_ENDPOINT = 'http://test.host/frontend-fixtures/builds-project/variables'; +const VARIABLE_PATCH_ENDPOINT = 'http://test.host/frontend-fixtures/builds-project/-/variables'; const HIDE_CLASS = 'hide'; describe('AjaxFormVariableList', () => { diff --git a/spec/lib/gitlab/fogbugz_import/client_spec.rb b/spec/lib/gitlab/fogbugz_import/client_spec.rb index dcd1a2d9813..676511211c8 100644 --- a/spec/lib/gitlab/fogbugz_import/client_spec.rb +++ b/spec/lib/gitlab/fogbugz_import/client_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe Gitlab::FogbugzImport::Client do diff --git a/spec/lib/gitlab/graphql/loaders/pipeline_for_sha_loader_spec.rb b/spec/lib/gitlab/graphql/loaders/pipeline_for_sha_loader_spec.rb deleted file mode 100644 index 136027736c3..00000000000 --- a/spec/lib/gitlab/graphql/loaders/pipeline_for_sha_loader_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -require 'spec_helper' - -describe Gitlab::Graphql::Loaders::PipelineForShaLoader do - include GraphqlHelpers - - describe '#find_last' do - it 'batch-resolves latest pipeline' do - project = create(:project, :repository) - pipeline1 = create(:ci_pipeline, project: project, ref: project.default_branch, sha: project.commit.sha) - pipeline2 = create(:ci_pipeline, project: project, ref: project.default_branch, sha: project.commit.sha) - pipeline3 = create(:ci_pipeline, project: project, ref: 'improve/awesome', sha: project.commit('improve/awesome').sha) - - result = batch_sync(max_queries: 1) do - [pipeline1.sha, pipeline3.sha].map { |sha| described_class.new(project, sha).find_last } - end - - expect(result).to contain_exactly(pipeline2, pipeline3) - end - end -end diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb index b86663fd7d9..0f7f68e0b38 100644 --- a/spec/models/merge_request_diff_spec.rb +++ b/spec/models/merge_request_diff_spec.rb @@ -378,6 +378,14 @@ describe MergeRequestDiff do expect(diff_with_commits.commit_shas).not_to be_empty expect(diff_with_commits.commit_shas).to all(match(/\h{40}/)) end + + context 'with limit attribute' do + it 'returns limited number of shas' do + expect(diff_with_commits.commit_shas(limit: 2).size).to eq(2) + expect(diff_with_commits.commit_shas(limit: 100).size).to eq(29) + expect(diff_with_commits.commit_shas.size).to eq(29) + end + end end describe '#compare_with' do diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index f775dfb87a2..b19f7a80d63 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1261,13 +1261,49 @@ describe MergeRequest do end describe '#commit_shas' do - before do - allow(subject.merge_request_diff).to receive(:commit_shas) - .and_return(['sha1']) + context 'persisted merge request' do + context 'with a limit' do + it 'returns a limited number of commit shas' do + expect(subject.commit_shas(limit: 2)).to eq(%w[ + b83d6e391c22777fca1ed3012fce84f633d7fed0 498214de67004b1da3d820901307bed2a68a8ef6 + ]) + end + end + + context 'without a limit' do + it 'returns all commit shas of the merge request diff' do + expect(subject.commit_shas.size).to eq(29) + end + end end - it 'delegates to merge request diff' do - expect(subject.commit_shas).to eq ['sha1'] + context 'new merge request' do + subject { build(:merge_request) } + + context 'compare commits' do + before do + subject.compare_commits = [ + double(sha: 'sha1'), double(sha: 'sha2') + ] + end + + context 'without a limit' do + it 'returns all shas of compare commits' do + expect(subject.commit_shas).to eq(%w[sha2 sha1]) + end + end + + context 'with a limit' do + it 'returns a limited number of shas' do + expect(subject.commit_shas(limit: 1)).to eq(['sha2']) + end + end + end + + it 'returns diff_head_sha as an array' do + expect(subject.commit_shas).to eq([subject.diff_head_sha]) + expect(subject.commit_shas(limit: 2)).to eq([subject.diff_head_sha]) + end end end diff --git a/spec/requests/api/group_export_spec.rb b/spec/requests/api/group_export_spec.rb new file mode 100644 index 00000000000..ac4853e5388 --- /dev/null +++ b/spec/requests/api/group_export_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::GroupExport do + let_it_be(:group) { create(:group) } + let_it_be(:user) { create(:user) } + + let(:path) { "/groups/#{group.id}/export" } + let(:download_path) { "/groups/#{group.id}/export/download" } + + let(:export_path) { "#{Dir.tmpdir}/group_export_spec" } + + before do + allow_next_instance_of(Gitlab::ImportExport) do |import_export| + expect(import_export).to receive(:storage_path).and_return(export_path) + end + end + + after do + FileUtils.rm_rf(export_path, secure: true) + end + + describe 'GET /groups/:group_id/export/download' do + let(:upload) { ImportExportUpload.new(group: group) } + + before do + stub_uploads_object_storage(ImportExportUploader) + + group.add_owner(user) + end + + context 'when export file exists' do + before do + upload.export_file = fixture_file_upload('spec/fixtures/group_export.tar.gz', "`/tar.gz") + upload.save! + end + + it 'downloads exported group archive' do + get api(download_path, user) + + expect(response).to have_gitlab_http_status(200) + end + + context 'when export_file.file does not exist' do + before do + expect_next_instance_of(ImportExportUploader) do |uploader| + expect(uploader).to receive(:file).and_return(nil) + end + end + + it 'returns 404' do + get api(download_path, user) + + expect(response).to have_gitlab_http_status(404) + end + end + end + + context 'when export file does not exist' do + it 'returns 404' do + get api(download_path, user) + + expect(response).to have_gitlab_http_status(404) + end + end + end + + describe 'POST /groups/:group_id/export' do + context 'when user is a group owner' do + before do + group.add_owner(user) + end + + it 'accepts download' do + post api(path, user) + + expect(response).to have_gitlab_http_status(202) + end + end + + context 'when user is not a group owner' do + before do + group.add_developer(user) + end + + it 'forbids the request' do + post api(path, user) + + expect(response).to have_gitlab_http_status(403) + end + end + end +end diff --git a/spec/support/helpers/access_matchers_helpers.rb b/spec/support/helpers/access_matchers_helpers.rb new file mode 100644 index 00000000000..9100f245d36 --- /dev/null +++ b/spec/support/helpers/access_matchers_helpers.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module AccessMatchersHelpers + USER_ACCESSOR_METHOD_NAME = 'user' + + def provide_user(role, membership = nil) + case role + when :admin + create(:admin) + when :auditor + create(:user, :auditor) + when :user + create(:user) + when :external + create(:user, :external) + when :visitor, :anonymous + nil + when User + role + when *Gitlab::Access.sym_options_with_owner.keys # owner, maintainer, developer, reporter, guest + raise ArgumentError, "cannot provide #{role} when membership reference is blank" unless membership + + provide_user_by_membership(role, membership) + else + raise ArgumentError, "cannot provide user of an unknown role #{role}" + end + end + + def provide_user_by_membership(role, membership) + if role == :owner && membership.owner + membership.owner + else + create(:user).tap do |user| + membership.public_send(:"add_#{role}", user) + end + end + end + + def raise_if_non_block_expectation!(actual) + raise ArgumentError, 'This matcher supports block expectations only.' unless actual.is_a?(Proc) + end + + def update_owner(objects, user) + return unless objects + + objects.each do |object| + if object.respond_to?(:owner) + object.update_attribute(:owner, user) + elsif object.respond_to?(:user) + object.update_attribute(:user, user) + else + raise ArgumentError, "cannot own this object #{object}" + end + end + end + + def patch_example_group(user) + return if user.nil? # for anonymous users + + # This call is evaluated in context of ExampleGroup instance in which the matcher is called. Overrides the `user` + # (or defined by `method_name`) method generated by `let` definition in example group before it's used by `subject`. + # This override is per concrete example only because the example group class gets re-created for each example. + instance_eval(<<~CODE, __FILE__, __LINE__ + 1) + if instance_variable_get(:@__#{USER_ACCESSOR_METHOD_NAME}_patched) + raise ArgumentError, 'An access matcher be_allowed_for/be_denied_for can be used only once per example (`it` block)' + end + instance_variable_set(:@__#{USER_ACCESSOR_METHOD_NAME}_patched, true) + + def #{USER_ACCESSOR_METHOD_NAME} + @#{USER_ACCESSOR_METHOD_NAME} ||= User.find(#{user.id}) + end + CODE + end + + def prepare_matcher_environment(role, membership, owned_objects) + user = provide_user(role, membership) + + if user + update_owner(owned_objects, user) + patch_example_group(user) + end + end + + def run_matcher(action, role, membership, owned_objects) + raise_if_non_block_expectation!(action) + + prepare_matcher_environment(role, membership, owned_objects) + + if block_given? + yield action + else + action.call + end + end +end diff --git a/spec/support/matchers/access_matchers_for_request.rb b/spec/support/matchers/access_matchers_for_request.rb new file mode 100644 index 00000000000..9b80bf8562c --- /dev/null +++ b/spec/support/matchers/access_matchers_for_request.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# AccessMatchersForRequest +# +# Matchers to test the access permissions for requests specs (most useful for API tests). +module AccessMatchersForRequest + extend RSpec::Matchers::DSL + include AccessMatchersHelpers + + EXPECTED_STATUS_CODES_ALLOWED = [200, 201, 204, 302, 304].freeze + EXPECTED_STATUS_CODES_DENIED = [401, 403, 404].freeze + + def description_for(role, type, expected, result) + "be #{type} for #{role} role. Expected status code: any of #{expected.join(', ')} Got: #{result}" + end + + matcher :be_allowed_for do |role| + match do |action| + # methods called in this and negated block are being run in context of ExampleGroup + # (not matcher) instance so we have to pass data via local vars + + run_matcher(action, role, @membership, @owned_objects) + + EXPECTED_STATUS_CODES_ALLOWED.include?(response.status) + end + + match_when_negated do |action| + run_matcher(action, role, @membership, @owned_objects) + + EXPECTED_STATUS_CODES_DENIED.include?(response.status) + end + + chain :of do |membership| + @membership = membership + end + + chain :own do |*owned_objects| + @owned_objects = owned_objects + end + + failure_message do + "expected this action to #{description_for(role, 'allowed', EXPECTED_STATUS_CODES_ALLOWED, response.status)}" + end + + failure_message_when_negated do + "expected this action to #{description_for(role, 'denied', EXPECTED_STATUS_CODES_DENIED, response.status)}" + end + + supports_block_expectations + end + + RSpec::Matchers.define_negated_matcher :be_denied_for, :be_allowed_for +end diff --git a/spec/support/matchers/access_matchers_generic.rb b/spec/support/matchers/access_matchers_generic.rb new file mode 100644 index 00000000000..13955750f4f --- /dev/null +++ b/spec/support/matchers/access_matchers_generic.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +# AccessMatchersGeneric +# +# Matchers to test the access permissions for service classes or other generic pieces of business logic. +module AccessMatchersGeneric + extend RSpec::Matchers::DSL + include AccessMatchersHelpers + + ERROR_CLASS = Gitlab::Access::AccessDeniedError + + def error_message(error) + str = error.class.name + str += ": #{error.message}" if error.message != error.class.name + str + end + + def error_expectation_message(allowed, error) + if allowed + "Expected to raise nothing but #{error_message(error)} was raised." + else + "Expected to raise #{ERROR_CLASS} but nothing was raised." + end + end + + def description_for(role, type, error) + allowed = type == 'allowed' + "be #{type} for #{role} role. #{error_expectation_message(allowed, error)}" + end + + matcher :be_allowed_for do |role| + match do |action| + # methods called in this and negated block are being run in context of ExampleGroup + # (not matcher) instance so we have to pass data via local vars + + run_matcher(action, role, @membership, @owned_objects) do |action| + action.call + rescue => e + @error = e + raise unless e.is_a?(ERROR_CLASS) + end + + @error.nil? + end + + chain :of do |membership| + @membership = membership + end + + chain :own do |*owned_objects| + @owned_objects = owned_objects + end + + failure_message do + "expected this action to #{description_for(role, 'allowed', @error)}" + end + + failure_message_when_negated do + "expected this action to #{description_for(role, 'denied', @error)}" + end + + supports_block_expectations + end + + RSpec::Matchers.define_negated_matcher :be_denied_for, :be_allowed_for +end |