diff options
36 files changed, 454 insertions, 234 deletions
diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue index 7280fba9e7a..9c0c97bc5ae 100644 --- a/app/assets/javascripts/ide/components/jobs/detail/description.vue +++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue @@ -26,7 +26,7 @@ export default { <ci-icon :status="job.status" :borderless="true" :size="24" class="d-flex" /> <span class="prepend-left-8"> {{ job.name }} - <a :href="job.path" target="_blank" class="ide-external-link"> + <a :href="job.path" target="_blank" class="ide-external-link position-relative"> {{ jobId }} <icon :size="12" name="external-link" /> </a> </span> diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 52ca61c06b0..ba8407382f4 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -71,7 +71,7 @@ export default { v-tooltip="showTooltip" :title="showTooltip ? stage.name : null" data-container="body" - class="prepend-left-8 ide-stage-title" + class="prepend-left-8 text-truncate" > {{ stage.name }} </strong> @@ -80,7 +80,7 @@ export default { </div> <icon :name="collapseIcon" class="ide-stage-collapse-icon" /> </div> - <div v-show="!stage.isCollapsed" ref="jobList" class="card-body"> + <div v-show="!stage.isCollapsed" ref="jobList" class="card-body p-0"> <gl-loading-icon v-if="showLoadingIcon" /> <template v-else> <item v-for="job in stage.jobs" :key="job.id" :job="job" @clickViewLog="clickViewLog" /> diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index b2fa020fb00..bcaaa8e09c2 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -51,7 +51,7 @@ export default { </script> <template> - <div class="ide-new-btn"> + <div class="ide-new-btn d-none"> <div :class="{ show: isOpen, diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue index e52613086a4..0efb0012246 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue @@ -43,21 +43,28 @@ export default { }, createFile(target, file) { const { name } = file; - let { result } = target; - const encodedContent = result.split('base64,')[1]; + const encodedContent = target.result.split('base64,')[1]; const rawContent = encodedContent ? atob(encodedContent) : ''; const isText = this.isText(rawContent, file.type); - result = isText ? rawContent : encodedContent; + const emitCreateEvent = content => + this.$emit('create', { + name: `${this.path ? `${this.path}/` : ''}${name}`, + type: 'blob', + content, + base64: !isText, + binary: !isText, + rawPath: !isText ? target.result : '', + }); - this.$emit('create', { - name: `${this.path ? `${this.path}/` : ''}${name}`, - type: 'blob', - content: result, - base64: !isText, - binary: !isText, - rawPath: !isText ? target.result : '', - }); + if (isText) { + const reader = new FileReader(); + + reader.addEventListener('load', e => emitCreateEvent(e.target.result), { once: true }); + reader.readAsText(file); + } else { + emitCreateEvent(encodedContent); + } }, readFile(file) { const reader = new FileReader(); diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 5ae73b2fc9c..b61d0a47795 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -62,7 +62,11 @@ export default { <ci-icon :status="latestPipeline.details.status" :size="24" /> <span class="prepend-left-8"> <strong> {{ __('Pipeline') }} </strong> - <a :href="latestPipeline.path" target="_blank" class="ide-external-link"> + <a + :href="latestPipeline.path" + target="_blank" + class="ide-external-link position-relative" + > #{{ latestPipeline.id }} <icon :size="12" name="external-link" /> </a> </span> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 7e2ab96d1de..c8c3036812e 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -274,7 +274,7 @@ export default { <template> <div id="ide" class="blob-viewer-container blob-editor-container"> <div class="ide-mode-tabs clearfix"> - <ul v-if="!shouldHideEditor && isEditModeActive" class="nav-links float-left"> + <ul v-if="!shouldHideEditor && isEditModeActive" class="nav-links float-left border-bottom-0"> <li :class="editTabCSS"> <a href="javascript:void(0);" diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js b/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js index 0eba9c39817..7576b2477d1 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/mutations.js @@ -14,9 +14,10 @@ export default { iid: mergeRequest.iid, title: mergeRequest.title, projectId: mergeRequest.project_id, - projectPathWithNamespace: mergeRequest.web_url - .replace(`${gon.gitlab_url}/`, '') - .replace(`/merge_requests/${mergeRequest.iid}`, ''), + projectPathWithNamespace: mergeRequest.references.full.replace( + mergeRequest.references.short, + '', + ), })); }, [types.RESET_MERGE_REQUESTS](state) { diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue index fe1a2a092ad..e80cb06edfb 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue @@ -13,6 +13,11 @@ export default { type: String, required: true, }, + filePath: { + type: String, + required: false, + default: '', + }, fileSize: { type: Number, required: false, @@ -24,7 +29,8 @@ export default { return numberToHumanSize(this.fileSize); }, fileName() { - return this.path.split('/').pop(); + // path could be a base64 uri too, so check if filePath was passed additionally + return (this.filePath || this.path).split('/').pop(); }, }, }; @@ -39,7 +45,13 @@ export default { ({{ fileSizeReadable }}) </template> </p> - <gl-link :href="path" class="btn btn-default" rel="nofollow" download target="_blank"> + <gl-link + :href="path" + class="btn btn-default" + rel="nofollow" + :download="fileName" + target="_blank" + > <icon :size="16" name="download" class="float-left append-right-8" /> {{ __('Download') }} </gl-link> diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 420271c9a1e..5eaff7702f6 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -25,10 +25,6 @@ $ide-commit-header-height: 48px; @include str-truncated(250px); } -.editable-mode { - display: inline-block; -} - .ide-view { position: relative; margin-top: 0; @@ -332,23 +328,6 @@ $ide-commit-header-height: 48px; padding: $gl-padding; max-width: 100%; max-height: 100%; - - img { - max-width: 90%; - } - - .isZoomable { - cursor: pointer; - cursor: zoom-in; - - &.isZoomed { - cursor: pointer; - cursor: zoom-out; - max-width: none; - max-height: none; - margin-right: $gl-padding; - } - } } .file-info { @@ -361,13 +340,9 @@ $ide-commit-header-height: 48px; .ide-mode-tabs { border-bottom: 1px solid $white-dark; - .nav-links { - border-bottom: 0; - - li a { - padding: $gl-padding-8 $gl-padding; - line-height: $gl-btn-line-height; - } + li a { + padding: $gl-padding-8 $gl-padding; + line-height: $gl-btn-line-height; } } @@ -564,12 +539,6 @@ $ide-commit-header-height: 48px; background: $gray-100; outline: 0; - - .multi-file-discard-btn { - > .btn { - display: flex; - } - } } &:active { @@ -596,18 +565,6 @@ $ide-commit-header-height: 48px; } } -.multi-file-discard-btn { - > .btn { - display: none; - width: $ide-commit-row-height; - height: $ide-commit-row-height; - } - - svg { - top: 0; - } -} - .multi-file-commit-form { position: relative; background-color: $white-light; @@ -1060,8 +1017,6 @@ $ide-commit-header-height: 48px; } .ide-external-link { - position: relative; - svg { display: none; position: absolute; @@ -1164,22 +1119,12 @@ $ide-commit-header-height: 48px; align-items: center; } } - - .card-body { - padding: 0; - } } .ide-stage-collapse-icon { margin: auto 0 auto auto; } -.ide-stage-title { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - .ide-job-header { min-height: 60px; } @@ -1279,8 +1224,6 @@ $ide-commit-header-height: 48px; } .ide-new-btn { - display: none; - .btn { padding: 2px 5px; } diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index e21930c9cfe..8c0188e1783 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -21,6 +21,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action only: [:show] do push_frontend_feature_flag(:diffs_batch_load, @project) push_frontend_feature_flag(:single_mr_diff_view, @project) + push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline) end before_action do diff --git a/app/graphql/types/snippets/blob_type.rb b/app/graphql/types/snippets/blob_type.rb index f398fe9c121..cacb2177192 100644 --- a/app/graphql/types/snippets/blob_type.rb +++ b/app/graphql/types/snippets/blob_type.rb @@ -40,6 +40,10 @@ module Types field :rich_viewer, type: Types::Snippets::BlobViewerType, description: 'Blob content rich viewer', null: true + + field :mode, type: GraphQL::STRING_TYPE, + description: 'Blob mode', + null: true end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/models/repository.rb b/app/models/repository.rb index e7ad38864c8..c6573e0bad2 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -41,8 +41,8 @@ class Repository CACHED_METHODS = %i(size commit_count rendered_readme readme_path contribution_guide changelog license_blob license_key gitignore gitlab_ci_yml branch_names tag_names branch_count - tag_count avatar exists? root_ref has_visible_content? - issue_template_names merge_request_template_names + tag_count avatar exists? root_ref merged_branch_names + has_visible_content? issue_template_names merge_request_template_names metrics_dashboard_paths xcode_project?).freeze # Methods that use cache_method but only memoize the value @@ -65,6 +65,8 @@ class Repository xcode_config: :xcode_project? }.freeze + MERGED_BRANCH_NAMES_CACHE_DURATION = 10.minutes + def initialize(full_path, project, disk_path: nil, repo_type: Gitlab::GlRepository::PROJECT) @full_path = full_path @disk_path = disk_path || full_path @@ -296,7 +298,7 @@ class Repository end def expire_branches_cache - expire_method_caches(%i(branch_names branch_count has_visible_content?)) + expire_method_caches(%i(branch_names merged_branch_names branch_count has_visible_content?)) @local_branches = nil @branch_exists_memo = nil end @@ -916,7 +918,39 @@ class Repository @root_ref_sha ||= commit(root_ref).sha end - delegate :merged_branch_names, to: :raw_repository + def merged_branch_names(branch_names = []) + # Currently we should skip caching if requesting all branch names + # This is only used in a few places, notably app/services/branches/delete_merged_service.rb, + # and it could potentially result in a very large cache/performance issues with the current + # implementation. + skip_cache = branch_names.empty? || Feature.disabled?(:merged_branch_names_redis_caching) + return raw_repository.merged_branch_names(branch_names) if skip_cache + + cached_branch_names = cache.read(:merged_branch_names) + merged_branch_names_hash = cached_branch_names || {} + missing_branch_names = branch_names.select { |bn| !merged_branch_names_hash.key?(bn) } + + # Track some metrics here whilst feature flag is enabled + if cached_branch_names.present? + counter = Gitlab::Metrics.counter( + :gitlab_repository_merged_branch_names_cache_hit, + "Count of cache hits for Repository#merged_branch_names" + ) + counter.increment(full_hit: missing_branch_names.empty?) + end + + if missing_branch_names.any? + merged = raw_repository.merged_branch_names(missing_branch_names) + + missing_branch_names.each do |bn| + merged_branch_names_hash[bn] = merged.include?(bn) + end + + cache.write(:merged_branch_names, merged_branch_names_hash, expires_in: MERGED_BRANCH_NAMES_CACHE_DURATION) + end + + Set.new(merged_branch_names_hash.select { |_, v| v }.keys) + end def merge_base(*commits_or_ids) commit_ids = commits_or_ids.map do |commit_or_id| diff --git a/changelogs/unreleased/26113-webide-upload-encoding.yml b/changelogs/unreleased/26113-webide-upload-encoding.yml new file mode 100644 index 00000000000..f42973f36de --- /dev/null +++ b/changelogs/unreleased/26113-webide-upload-encoding.yml @@ -0,0 +1,5 @@ +--- +title: Fix some of the file encoding issues when uploading in the Web IDE +merge_request: 23761 +author: +type: fixed diff --git a/changelogs/unreleased/fj-add-mode-field-to-snippet-blob-type.yml b/changelogs/unreleased/fj-add-mode-field-to-snippet-blob-type.yml new file mode 100644 index 00000000000..03ed04d9057 --- /dev/null +++ b/changelogs/unreleased/fj-add-mode-field-to-snippet-blob-type.yml @@ -0,0 +1,5 @@ +--- +title: Add mode field to snippet blob in GraphQL +merge_request: 24157 +author: +type: changed diff --git a/changelogs/unreleased/refactoring-entities-file-10.yml b/changelogs/unreleased/refactoring-entities-file-10.yml new file mode 100644 index 00000000000..bc78c6d4fa6 --- /dev/null +++ b/changelogs/unreleased/refactoring-entities-file-10.yml @@ -0,0 +1,5 @@ +--- +title: Separate issue entities into own class files +merge_request: 24226 +author: Rajendra Kadam +type: added diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index eb728233379..2c63ecfe08e 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -6642,6 +6642,11 @@ type SnippetBlob { highlightedData: String """ + Blob mode + """ + mode: String + + """ Blob name """ name: String diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index d346c8a886f..a35bf8caccf 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -7172,6 +7172,20 @@ "deprecationReason": null }, { + "name": "mode", + "description": "Blob mode", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "name", "description": "Blob name", "args": [ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 7360ce0978a..a0d1787e816 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1063,6 +1063,7 @@ Represents the snippet blob | --- | ---- | ---------- | | `binary` | Boolean! | Shows whether the blob is binary | | `highlightedData` | String | Blob highlighted data | +| `mode` | String | Blob mode | | `name` | String | Blob name | | `path` | String | Blob path | | `rawPath` | String! | Blob raw content endpoint path | diff --git a/doc/development/api_styleguide.md b/doc/development/api_styleguide.md index d5fc24c1ddb..2510358b4d5 100644 --- a/doc/development/api_styleguide.md +++ b/doc/development/api_styleguide.md @@ -19,7 +19,7 @@ for a good example): - `desc` for the method summary. You should pass it a block for additional details such as: - - The GitLab version when the endpoint was added + - The GitLab version when the endpoint was added. If it is behind a feature flag, mention that instead: _This feature is gated by the :feature\_flag\_symbol feature flag._ - If the endpoint is deprecated, and if so, when will it be removed - `params` for the method params. This acts as description, diff --git a/lib/api/entities.rb b/lib/api/entities.rb index bd7b2fd9433..2da9a042978 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -128,127 +128,6 @@ module API end end - class Milestone < Grape::Entity - expose :id, :iid - expose :project_id, if: -> (entity, options) { entity&.project_id } - expose :group_id, if: -> (entity, options) { entity&.group_id } - expose :title, :description - expose :state, :created_at, :updated_at - expose :due_date - expose :start_date - - expose :web_url do |milestone, _options| - Gitlab::UrlBuilder.build(milestone) - end - end - - class IssueBasic < IssuableEntity - expose :closed_at - expose :closed_by, using: Entities::UserBasic - - expose :labels do |issue, options| - if options[:with_labels_details] - ::API::Entities::LabelBasic.represent(issue.labels.sort_by(&:title)) - else - issue.labels.map(&:title).sort - end - end - - expose :milestone, using: Entities::Milestone - expose :assignees, :author, using: Entities::UserBasic - - expose :assignee, using: ::API::Entities::UserBasic do |issue| - issue.assignees.first - end - - expose(:user_notes_count) { |issue, options| issuable_metadata(issue, options, :user_notes_count) } - expose(:merge_requests_count) { |issue, options| issuable_metadata(issue, options, :merge_requests_count, options[:current_user]) } - expose(:upvotes) { |issue, options| issuable_metadata(issue, options, :upvotes) } - expose(:downvotes) { |issue, options| issuable_metadata(issue, options, :downvotes) } - expose :due_date - expose :confidential - expose :discussion_locked - - expose :web_url do |issue| - Gitlab::UrlBuilder.build(issue) - end - - expose :time_stats, using: 'API::Entities::IssuableTimeStats' do |issue| - issue - end - - expose :task_completion_status - end - - class Issue < IssueBasic - include ::API::Helpers::RelatedResourcesHelpers - - expose(:has_tasks) do |issue, _| - !issue.task_list_items.empty? - end - - expose :task_status, if: -> (issue, _) do - !issue.task_list_items.empty? - end - - expose :_links do - expose :self do |issue| - expose_url(api_v4_project_issue_path(id: issue.project_id, issue_iid: issue.iid)) - end - - expose :notes do |issue| - expose_url(api_v4_projects_issues_notes_path(id: issue.project_id, noteable_id: issue.iid)) - end - - expose :award_emoji do |issue| - expose_url(api_v4_projects_issues_award_emoji_path(id: issue.project_id, issue_iid: issue.iid)) - end - - expose :project do |issue| - expose_url(api_v4_projects_path(id: issue.project_id)) - end - end - - expose :references, with: IssuableReferences do |issue| - issue - end - - # Calculating the value of subscribed field triggers Markdown - # processing. We can't do that for multiple issues / merge - # requests in a single API request. - expose :subscribed, if: -> (_, options) { options.fetch(:include_subscribed, true) } do |issue, options| - issue.subscribed?(options[:current_user], options[:project] || issue.project) - end - - expose :moved_to_id - end - - class IssuableTimeStats < Grape::Entity - format_with(:time_tracking_formatter) do |time_spent| - Gitlab::TimeTrackingFormatter.output(time_spent) - end - - expose :time_estimate - expose :total_time_spent - expose :human_time_estimate - - with_options(format_with: :time_tracking_formatter) do - expose :total_time_spent, as: :human_total_time_spent - end - - # rubocop: disable CodeReuse/ActiveRecord - def total_time_spent - # Avoids an N+1 query since timelogs are preloaded - object.timelogs.map(&:time_spent).sum - end - # rubocop: enable CodeReuse/ActiveRecord - end - - class ExternalIssue < Grape::Entity - expose :title - expose :id - end - class PipelineBasic < Grape::Entity expose :id, :sha, :ref, :status expose :created_at, :updated_at diff --git a/lib/api/entities/external_issue.rb b/lib/api/entities/external_issue.rb new file mode 100644 index 00000000000..8a201f70099 --- /dev/null +++ b/lib/api/entities/external_issue.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module API + module Entities + class ExternalIssue < Grape::Entity + expose :title + expose :id + end + end +end diff --git a/lib/api/entities/issuable_time_stats.rb b/lib/api/entities/issuable_time_stats.rb new file mode 100644 index 00000000000..7c3452a10a1 --- /dev/null +++ b/lib/api/entities/issuable_time_stats.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module API + module Entities + class IssuableTimeStats < Grape::Entity + format_with(:time_tracking_formatter) do |time_spent| + Gitlab::TimeTrackingFormatter.output(time_spent) + end + + expose :time_estimate + expose :total_time_spent + expose :human_time_estimate + + with_options(format_with: :time_tracking_formatter) do + expose :total_time_spent, as: :human_total_time_spent + end + + # rubocop: disable CodeReuse/ActiveRecord + def total_time_spent + # Avoids an N+1 query since timelogs are preloaded + object.timelogs.map(&:time_spent).sum + end + # rubocop: enable CodeReuse/ActiveRecord + end + end +end diff --git a/lib/api/entities/issue.rb b/lib/api/entities/issue.rb new file mode 100644 index 00000000000..b7eb22b2aba --- /dev/null +++ b/lib/api/entities/issue.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module API + module Entities + class Issue < IssueBasic + include ::API::Helpers::RelatedResourcesHelpers + + expose(:has_tasks) do |issue, _| + !issue.task_list_items.empty? + end + + expose :task_status, if: -> (issue, _) do + !issue.task_list_items.empty? + end + + expose :_links do + expose :self do |issue| + expose_url(api_v4_project_issue_path(id: issue.project_id, issue_iid: issue.iid)) + end + + expose :notes do |issue| + expose_url(api_v4_projects_issues_notes_path(id: issue.project_id, noteable_id: issue.iid)) + end + + expose :award_emoji do |issue| + expose_url(api_v4_projects_issues_award_emoji_path(id: issue.project_id, issue_iid: issue.iid)) + end + + expose :project do |issue| + expose_url(api_v4_projects_path(id: issue.project_id)) + end + end + + expose :references, with: IssuableReferences do |issue| + issue + end + + # Calculating the value of subscribed field triggers Markdown + # processing. We can't do that for multiple issues / merge + # requests in a single API request. + expose :subscribed, if: -> (_, options) { options.fetch(:include_subscribed, true) } do |issue, options| + issue.subscribed?(options[:current_user], options[:project] || issue.project) + end + + expose :moved_to_id + end + end +end diff --git a/lib/api/entities/issue_basic.rb b/lib/api/entities/issue_basic.rb new file mode 100644 index 00000000000..7e4be35d20b --- /dev/null +++ b/lib/api/entities/issue_basic.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module API + module Entities + class IssueBasic < IssuableEntity + expose :closed_at + expose :closed_by, using: Entities::UserBasic + + expose :labels do |issue, options| + if options[:with_labels_details] + ::API::Entities::LabelBasic.represent(issue.labels.sort_by(&:title)) + else + issue.labels.map(&:title).sort + end + end + + expose :milestone, using: Entities::Milestone + expose :assignees, :author, using: Entities::UserBasic + + expose :assignee, using: ::API::Entities::UserBasic do |issue| + issue.assignees.first + end + + expose(:user_notes_count) { |issue, options| issuable_metadata(issue, options, :user_notes_count) } + expose(:merge_requests_count) { |issue, options| issuable_metadata(issue, options, :merge_requests_count, options[:current_user]) } + expose(:upvotes) { |issue, options| issuable_metadata(issue, options, :upvotes) } + expose(:downvotes) { |issue, options| issuable_metadata(issue, options, :downvotes) } + expose :due_date + expose :confidential + expose :discussion_locked + + expose :web_url do |issue| + Gitlab::UrlBuilder.build(issue) + end + + expose :time_stats, using: 'API::Entities::IssuableTimeStats' do |issue| + issue + end + + expose :task_completion_status + end + end +end diff --git a/lib/api/entities/milestone.rb b/lib/api/entities/milestone.rb new file mode 100644 index 00000000000..5a0c222d691 --- /dev/null +++ b/lib/api/entities/milestone.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module API + module Entities + class Milestone < Grape::Entity + expose :id, :iid + expose :project_id, if: -> (entity, options) { entity&.project_id } + expose :group_id, if: -> (entity, options) { entity&.group_id } + expose :title, :description + expose :state, :created_at, :updated_at + expose :due_date + expose :start_date + + expose :web_url do |milestone, _options| + Gitlab::UrlBuilder.build(milestone) + end + end + end +end diff --git a/lib/gitlab/experimentation.rb b/lib/gitlab/experimentation.rb index 2066b58dff5..9ceaa742f51 100644 --- a/lib/gitlab/experimentation.rb +++ b/lib/gitlab/experimentation.rb @@ -22,6 +22,12 @@ module Gitlab environment: ::Gitlab.dev_env_or_com?, enabled_ratio: 0.25, tracking_category: 'Growth::Acquisition::Experiment::PaidSignUpFlow' + }, + suggest_pipeline: { + feature_toggle: :suggest_pipeline, + environment: ::Gitlab.dev_env_or_com?, + enabled_ratio: 0.1, + tracking_category: 'Growth::Expansion::Experiment::SuggestPipeline' } }.freeze diff --git a/lib/gitlab/repository_cache.rb b/lib/gitlab/repository_cache.rb index fca8c43da2e..dc8b2467f72 100644 --- a/lib/gitlab/repository_cache.rb +++ b/lib/gitlab/repository_cache.rb @@ -33,8 +33,8 @@ module Gitlab backend.read(cache_key(key)) end - def write(key, value) - backend.write(cache_key(key), value) + def write(key, value, *args) + backend.write(cache_key(key), value, *args) end def fetch_without_caching_false(key, &block) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 260c3d2625b..c91bae519e5 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2104,6 +2104,9 @@ msgstr "" msgid "Approver" msgstr "" +msgid "Approvers" +msgstr "" + msgid "Apr" msgstr "" diff --git a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap index 43e606eac6e..db5175c3f7b 100644 --- a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap +++ b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap @@ -14,7 +14,7 @@ exports[`IDE pipeline stage renders stage details & icon 1`] = ` /> <strong - class="prepend-left-8 ide-stage-title" + class="prepend-left-8 text-truncate" data-container="body" data-original-title="" title="" @@ -42,7 +42,7 @@ exports[`IDE pipeline stage renders stage details & icon 1`] = ` </div> <div - class="card-body" + class="card-body p-0" > <item-stub job="[object Object]" diff --git a/spec/frontend/ide/mock_data.js b/spec/frontend/ide/mock_data.js index 80eb15fe5a6..a1b57dca6bc 100644 --- a/spec/frontend/ide/mock_data.js +++ b/spec/frontend/ide/mock_data.js @@ -165,7 +165,11 @@ export const mergeRequests = [ iid: 1, title: 'Test merge request', project_id: 1, - web_url: `${TEST_HOST}/namespace/project-path/merge_requests/1`, + web_url: `${TEST_HOST}/namespace/project-path/-/merge_requests/1`, + references: { + short: '!1', + full: 'namespace/project-path!1', + }, }, ]; diff --git a/spec/graphql/types/snippets/blob_type_spec.rb b/spec/graphql/types/snippets/blob_type_spec.rb index f1837538b53..e7d4e5dfa2d 100644 --- a/spec/graphql/types/snippets/blob_type_spec.rb +++ b/spec/graphql/types/snippets/blob_type_spec.rb @@ -6,7 +6,8 @@ describe GitlabSchema.types['SnippetBlob'] do it 'has the correct fields' do expected_fields = [:highlighted_data, :raw_path, :size, :binary, :name, :path, - :simple_viewer, :rich_viewer] + :simple_viewer, :rich_viewer, + :mode] is_expected.to have_graphql_fields(*expected_fields) end diff --git a/spec/javascripts/ide/components/new_dropdown/upload_spec.js b/spec/javascripts/ide/components/new_dropdown/upload_spec.js index 4ebd0977832..66ddf6c0ee6 100644 --- a/spec/javascripts/ide/components/new_dropdown/upload_spec.js +++ b/spec/javascripts/ide/components/new_dropdown/upload_spec.js @@ -14,7 +14,7 @@ describe('new dropdown upload', () => { vm.entryName = 'testing'; - spyOn(vm, '$emit'); + spyOn(vm, '$emit').and.callThrough(); }); afterEach(() => { @@ -61,31 +61,44 @@ describe('new dropdown upload', () => { const binaryTarget = { result: 'base64,w4I=', }; - const textFile = { - name: 'textFile', - type: 'text/plain', - }; + const textFile = new File(['plain text'], 'textFile'); + const binaryFile = { name: 'binaryFile', type: 'image/png', }; - it('creates file in plain text (without encoding) if the file content is plain text', () => { + beforeEach(() => { + spyOn(FileReader.prototype, 'readAsText').and.callThrough(); + }); + + it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', done => { + const waitForCreate = new Promise(resolve => vm.$on('create', resolve)); + vm.createFile(textTarget, textFile); - expect(vm.$emit).toHaveBeenCalledWith('create', { - name: textFile.name, - type: 'blob', - content: 'plain text', - base64: false, - binary: false, - rawPath: '', - }); + expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(textFile); + + waitForCreate + .then(() => { + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: textFile.name, + type: 'blob', + content: 'plain text', + base64: false, + binary: false, + rawPath: '', + }); + }) + .then(done) + .catch(done.fail); }); it('splits content on base64 if binary', () => { vm.createFile(binaryTarget, binaryFile); + expect(FileReader.prototype.readAsText).not.toHaveBeenCalledWith(textFile); + expect(vm.$emit).toHaveBeenCalledWith('create', { name: binaryFile.name, type: 'blob', diff --git a/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js b/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js index e3f6609f128..e2a1ed931f1 100644 --- a/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js +++ b/spec/javascripts/vue_shared/components/content_viewer/content_viewer_spec.js @@ -58,14 +58,34 @@ describe('ContentViewer', () => { it('renders fallback download control', done => { createComponent({ - path: 'test.abc', + path: 'somepath/test.abc', fileSize: 1024, }); setTimeout(() => { - expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain('test.abc'); - expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain('(1.00 KiB)'); - expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toContain('Download'); + expect( + vm.$el + .querySelector('.file-info') + .textContent.trim() + .replace(/\s+/, ' '), + ).toEqual('test.abc (1.00 KiB)'); + + expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toEqual('Download'); + + done(); + }); + }); + + it('renders fallback download control for file with a data URL path properly', done => { + createComponent({ + path: 'data:application/octet-stream;base64,U0VMRUNUICfEhHNnc2cnIGZyb20gVGFibGVuYW1lOwoK', + filePath: 'somepath/test.abc', + }); + + setTimeout(() => { + expect(vm.$el.querySelector('.file-info').textContent.trim()).toEqual('test.abc'); + expect(vm.$el.querySelector('.btn.btn-default')).toHaveAttr('download', 'test.abc'); + expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toEqual('Download'); done(); }); diff --git a/spec/lib/gitlab/repository_cache_spec.rb b/spec/lib/gitlab/repository_cache_spec.rb index 1b7dd1766da..e787288fc51 100644 --- a/spec/lib/gitlab/repository_cache_spec.rb +++ b/spec/lib/gitlab/repository_cache_spec.rb @@ -50,6 +50,18 @@ describe Gitlab::RepositoryCache do end end + describe '#write' do + it 'writes the given key and value to the cache' do + cache.write(:test, 'test') + expect(backend).to have_received(:write).with("test:#{namespace}", 'test') + end + + it 'passes additional options to the backend' do + cache.write(:test, 'test', expires_in: 10.minutes) + expect(backend).to have_received(:write).with("test:#{namespace}", 'test', expires_in: 10.minutes) + end + end + describe '#fetch_without_caching_false', :use_clean_rails_memory_store_caching do let(:key) { :foo } let(:backend) { Rails.cache } diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 19a45ce5f88..3d28adade05 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -494,6 +494,100 @@ describe Repository do it { is_expected.to eq(commit.sha) } end + describe "#merged_branch_names", :clean_gitlab_redis_cache do + subject { repository.merged_branch_names(branch_names) } + + let(:branch_names) { %w(test beep boop definitely_merged) } + let(:already_merged) { Set.new(["definitely_merged"]) } + + let(:merge_state_hash) do + { + "test" => false, + "beep" => false, + "boop" => false, + "definitely_merged" => true + } + end + + let_it_be(:cache) do + caching_config_hash = Gitlab::Redis::Cache.params + ActiveSupport::Cache.lookup_store(:redis_cache_store, caching_config_hash) + end + + let(:repository_cache) do + Gitlab::RepositoryCache.new(repository, backend: Rails.cache) + end + + let(:cache_key) { repository_cache.cache_key(:merged_branch_names) } + + before do + allow(Rails).to receive(:cache) { cache } + allow(repository).to receive(:cache) { repository_cache } + allow(repository.raw_repository).to receive(:merged_branch_names).with(branch_names).and_return(already_merged) + end + + it { is_expected.to eq(already_merged) } + it { is_expected.to be_a(Set) } + + context "cache is empty" do + before do + cache.delete(cache_key) + end + + it { is_expected.to eq(already_merged) } + + describe "cache values" do + it "writes the values to redis" do + expect(cache).to receive(:write).with(cache_key, merge_state_hash, expires_in: Repository::MERGED_BRANCH_NAMES_CACHE_DURATION) + + subject + end + + it "matches the supplied hash" do + subject + + expect(cache.read(cache_key)).to eq(merge_state_hash) + end + end + end + + context "cache is not empty" do + before do + cache.write(cache_key, merge_state_hash) + end + + it { is_expected.to eq(already_merged) } + + it "doesn't fetch from the disk" do + expect(repository.raw_repository).not_to receive(:merged_branch_names) + + subject + end + end + + context "cache is partially complete" do + before do + allow(repository.raw_repository).to receive(:merged_branch_names).with(["boop"]).and_return([]) + hash = merge_state_hash.except("boop") + cache.write(cache_key, hash) + end + + it { is_expected.to eq(already_merged) } + + it "does fetch from the disk" do + expect(repository.raw_repository).to receive(:merged_branch_names).with(["boop"]) + + subject + end + end + + context "requested branches array is empty" do + let(:branch_names) { [] } + + it { is_expected.to eq(already_merged) } + end + end + describe '#can_be_merged?' do context 'mergeable branches' do subject { repository.can_be_merged?('0b4bc9a49b562e85de7cc9e834518ea6828729b9', 'master') } @@ -1784,6 +1878,7 @@ describe Repository do :avatar, :exists?, :root_ref, + :merged_branch_names, :has_visible_content?, :issue_template_names, :merge_request_template_names, @@ -1959,7 +2054,7 @@ describe Repository do describe '#expire_branches_cache' do it 'expires the cache' do expect(repository).to receive(:expire_method_caches) - .with(%i(branch_names branch_count has_visible_content?)) + .with(%i(branch_names merged_branch_names branch_count has_visible_content?)) .and_call_original repository.expire_branches_cache diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb index 99374d28324..d4deb049ea7 100644 --- a/spec/requests/api/branches_spec.rb +++ b/spec/requests/api/branches_spec.rb @@ -608,7 +608,7 @@ describe API::Branches do expect(json_response['message']).to eq('Branch name is invalid') end - it 'returns 400 if branch already exists' do + it 'returns 400 if branch already exists', :clean_gitlab_redis_cache do post api(route, user), params: { branch: 'new_design1', ref: branch_sha } expect(response).to have_gitlab_http_status(201) |