Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.flayignore1
-rw-r--r--.gitlab/issue_templates/Security Developer Workflow.md4
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js9
-rw-r--r--app/assets/javascripts/gpg_badges.js6
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue106
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue16
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue45
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue171
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue103
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue3
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue30
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/success_message.vue8
-rw-r--r--app/assets/javascripts/ide/components/editor_mode_dropdown.vue80
-rw-r--r--app/assets/javascripts/ide/components/ide.vue129
-rw-r--r--app/assets/javascripts/ide/components/ide_context_bar.vue42
-rw-r--r--app/assets/javascripts/ide/components/ide_external_links.vue43
-rw-r--r--app/assets/javascripts/ide/components/ide_project_branches_tree.vue47
-rw-r--r--app/assets/javascripts/ide/components/ide_project_tree.vue65
-rw-r--r--app/assets/javascripts/ide/components/ide_repo_tree.vue41
-rw-r--r--app/assets/javascripts/ide/components/ide_review.vue62
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue143
-rw-r--r--app/assets/javascripts/ide/components/ide_tree.vue42
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue76
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue3
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue102
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue52
-rw-r--r--app/assets/javascripts/ide/components/repo_file.vue13
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue36
-rw-r--r--app/assets/javascripts/ide/components/repo_tabs.vue17
-rw-r--r--app/assets/javascripts/ide/constants.js16
-rw-r--r--app/assets/javascripts/ide/ide_router.js7
-rw-r--r--app/assets/javascripts/ide/index.js15
-rw-r--r--app/assets/javascripts/ide/lib/editor.js8
-rw-r--r--app/assets/javascripts/ide/stores/actions.js14
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js9
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js1
-rw-r--r--app/assets/javascripts/ide/stores/getters.js23
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js39
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js15
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js45
-rw-r--r--app/assets/javascripts/ide/stores/state.js5
-rw-r--r--app/assets/javascripts/mini_pipeline_graph_dropdown.js4
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue6
-rw-r--r--app/assets/javascripts/pages/projects/compare/show/index.js2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/stage.vue244
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js4
-rw-r--r--app/assets/javascripts/projects_dropdown/components/app.vue5
-rw-r--r--app/assets/javascripts/projects_dropdown/service/projects_service.js14
-rw-r--r--app/assets/stylesheets/framework/gitlab_theme.scss30
-rw-r--r--app/assets/stylesheets/framework/terms.scss4
-rw-r--r--app/assets/stylesheets/framework/variables.scss2
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss4
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss194
-rw-r--r--app/assets/stylesheets/pages/repo.scss401
-rw-r--r--app/controllers/projects/compare_controller.rb65
-rw-r--r--app/controllers/projects/mirrors_controller.rb67
-rw-r--r--app/controllers/projects/pipelines_controller.rb15
-rw-r--r--app/controllers/projects/settings/repository_controller.rb6
-rw-r--r--app/helpers/application_helper.rb13
-rw-r--r--app/helpers/application_settings_helper.rb3
-rw-r--r--app/models/ability.rb8
-rw-r--r--app/models/application_setting.rb3
-rw-r--r--app/models/ci/build.rb1
-rw-r--r--app/models/ci/build_trace_chunk.rb180
-rw-r--r--app/models/concerns/fast_destroy_all.rb91
-rw-r--r--app/models/concerns/participable.rb4
-rw-r--r--app/models/group.rb31
-rw-r--r--app/models/namespace.rb7
-rw-r--r--app/models/project.rb46
-rw-r--r--app/models/remote_mirror.rb219
-rw-r--r--app/models/repository.rb16
-rw-r--r--app/policies/project_policy.rb7
-rw-r--r--app/serializers/project_mirror_entity.rb11
-rw-r--r--app/serializers/stage_entity.rb16
-rw-r--r--app/serializers/stage_serializer.rb7
-rw-r--r--app/services/concerns/exclusive_lease_guard.rb52
-rw-r--r--app/services/concerns/users/participable_service.rb41
-rw-r--r--app/services/git_push_service.rb8
-rw-r--r--app/services/projects/participants_service.rb32
-rw-r--r--app/services/projects/update_remote_mirror_service.rb30
-rw-r--r--app/views/admin/application_settings/_repository_check.html.haml2
-rw-r--r--app/views/admin/application_settings/_repository_mirrors_form.html.haml16
-rw-r--r--app/views/admin/application_settings/show.html.haml11
-rw-r--r--app/views/ci/status/_dropdown_graph_badge.html.haml2
-rw-r--r--app/views/layouts/_init_auto_complete.html.haml15
-rw-r--r--app/views/projects/commits/show.html.haml2
-rw-r--r--app/views/projects/compare/_form.html.haml2
-rw-r--r--app/views/projects/mirrors/_instructions.html.haml10
-rw-r--r--app/views/projects/mirrors/_push.html.haml50
-rw-r--r--app/views/projects/mirrors/_show.html.haml3
-rw-r--r--app/views/projects/settings/repository/show.html.haml2
-rw-r--r--app/views/shared/_mini_pipeline_graph.html.haml3
-rw-r--r--app/views/shared/_remote_mirror_update_button.html.haml13
-rw-r--r--app/workers/admin_email_worker.rb6
-rw-r--r--app/workers/all_queues.yml3
-rw-r--r--app/workers/ci/build_trace_chunk_flush_worker.rb12
-rw-r--r--app/workers/repository_check/batch_worker.rb35
-rw-r--r--app/workers/repository_check/single_repository_worker.rb48
-rw-r--r--app/workers/repository_remove_remote_worker.rb35
-rw-r--r--app/workers/repository_update_remote_mirror_worker.rb49
-rw-r--r--changelogs/unreleased/33697-pipelines-json-endpoint.yml5
-rw-r--r--changelogs/unreleased/42099-port-push-mirroring-to-ce-ce-port-v-2.yml5
-rw-r--r--changelogs/unreleased/44879.yml5
-rw-r--r--changelogs/unreleased/issue_43660.yml5
-rw-r--r--changelogs/unreleased/live-trace-v2-efficient-destroy-all.yml5
-rw-r--r--changelogs/unreleased/live-trace-v2.yml5
-rw-r--r--changelogs/unreleased/tc-repo-verify-mails.yml5
-rw-r--r--changelogs/unreleased/tz-upgrade-underscore.yml5
-rw-r--r--config/initializers/8_metrics.rb9
-rw-r--r--config/initializers/warden.rb10
-rw-r--r--config/prometheus/additional_metrics.yml12
-rw-r--r--config/routes/project.rb7
-rw-r--r--config/routes/repository.rb1
-rw-r--r--config/sidekiq_queues.yml3
-rw-r--r--db/migrate/20180326202229_create_ci_build_trace_chunks.rb17
-rw-r--r--db/migrate/20180406204716_add_limits_ci_build_trace_chunks_raw_data_for_mysql.rb13
-rw-r--r--db/migrate/20180420010616_cleanup_build_stage_migration.rb28
-rw-r--r--db/migrate/20180503131624_create_remote_mirrors.rb33
-rw-r--r--db/migrate/20180503141722_add_remote_mirror_available_overridden_to_projects.rb15
-rw-r--r--db/migrate/20180503193542_add_indexes_to_remote_mirror.rb15
-rw-r--r--db/migrate/20180503193953_add_mirror_available_to_application_settings.rb15
-rw-r--r--db/migrate/20180503200320_enable_prometheus_metrics_by_default.rb11
-rw-r--r--db/migrate/limits_ci_build_trace_chunks_raw_data_for_mysql.rb9
-rw-r--r--db/schema.rb38
-rw-r--r--doc/administration/job_traces.md95
-rw-r--r--doc/administration/repository_checks.md26
-rw-r--r--doc/development/fe_guide/index.md3
-rw-r--r--doc/development/fe_guide/vue.md278
-rw-r--r--doc/development/fe_guide/vuex.md358
-rw-r--r--doc/install/installation.md4
-rw-r--r--doc/user/project/integrations/prometheus_library/nginx.md10
-rw-r--r--doc/workflow/repository_mirroring.md111
-rw-r--r--doc/workflow/repository_mirroring/repository_mirroring_diverged_branch_push.pngbin0 -> 9512 bytes
-rw-r--r--doc/workflow/repository_mirroring/repository_mirroring_github_edit_personal_access_token.pngbin0 -> 20739 bytes
-rw-r--r--doc/workflow/repository_mirroring/repository_mirroring_gitlab_push_to_a_remote_repository.pngbin0 -> 16538 bytes
-rw-r--r--doc/workflow/repository_mirroring/repository_mirroring_gitlab_push_to_a_remote_repository_update_now.pngbin0 -> 16765 bytes
-rw-r--r--doc/workflow/repository_mirroring/repository_mirroring_push_settings.pngbin0 -> 18226 bytes
-rw-r--r--lib/api/runner.rb17
-rw-r--r--lib/gitlab/ci/trace.rb32
-rw-r--r--lib/gitlab/ci/trace/chunked_io.rb231
-rw-r--r--lib/gitlab/ci/trace/stream.rb11
-rw-r--r--lib/gitlab/git/raw_diff_change.rb10
-rw-r--r--lib/gitlab/git/repository.rb27
-rw-r--r--lib/gitlab/gitaly_client/repository_service.rb6
-rw-r--r--lib/gitlab/import_export/import_export.yml1
-rw-r--r--lib/gitlab/usage_data.rb1
-rw-r--r--lib/tasks/migrate/add_limits_mysql.rake2
-rw-r--r--package.json2
-rw-r--r--spec/controllers/projects/compare_controller_spec.rb310
-rw-r--r--spec/controllers/projects/jobs_controller_spec.rb3
-rw-r--r--spec/controllers/projects/mirrors_controller_spec.rb72
-rw-r--r--spec/controllers/projects/pipelines_controller_spec.rb39
-rw-r--r--spec/db/production/settings_spec.rb6
-rw-r--r--spec/factories/ci/build_trace_chunks.rb7
-rw-r--r--spec/factories/project_wikis.rb2
-rw-r--r--spec/factories/projects.rb18
-rw-r--r--spec/factories/remote_mirrors.rb6
-rw-r--r--spec/features/projects/jobs_spec.rb42
-rw-r--r--spec/features/projects/pipelines/pipelines_spec.rb6
-rw-r--r--spec/features/projects/remote_mirror_spec.rb34
-rw-r--r--spec/features/projects/settings/repository_settings_spec.rb15
-rw-r--r--spec/features/projects/tree/create_directory_spec.rb7
-rw-r--r--spec/features/projects/tree/create_file_spec.rb5
-rw-r--r--spec/features/projects/wiki/markdown_preview_spec.rb2
-rw-r--r--spec/features/projects/wiki/shortcuts_spec.rb2
-rw-r--r--spec/features/projects/wiki/user_creates_wiki_page_spec.rb10
-rw-r--r--spec/features/projects/wiki/user_deletes_wiki_page_spec.rb2
-rw-r--r--spec/features/projects/wiki/user_git_access_wiki_page_spec.rb2
-rw-r--r--spec/features/projects/wiki/user_updates_wiki_page_spec.rb8
-rw-r--r--spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb1
-rw-r--r--spec/features/projects/wiki/user_views_wiki_page_spec.rb2
-rw-r--r--spec/features/search/user_searches_for_wiki_pages_spec.rb2
-rw-r--r--spec/fixtures/api/schemas/ci_detailed_status.json24
-rw-r--r--spec/fixtures/api/schemas/job.json24
-rw-r--r--spec/fixtures/api/schemas/pipeline_stage.json24
-rw-r--r--spec/helpers/application_helper_spec.rb12
-rw-r--r--spec/javascripts/fixtures/mini_dropdown_graph.html.haml1
-rw-r--r--spec/javascripts/gpg_badges_spec.js4
-rw-r--r--spec/javascripts/ide/components/activity_bar_spec.js92
-rw-r--r--spec/javascripts/ide/components/commit_sidebar/empty_state_spec.js40
-rw-r--r--spec/javascripts/ide/components/commit_sidebar/form_spec.js146
-rw-r--r--spec/javascripts/ide/components/commit_sidebar/list_spec.js41
-rw-r--r--spec/javascripts/ide/components/ide_context_bar_spec.js37
-rw-r--r--spec/javascripts/ide/components/ide_external_links_spec.js43
-rw-r--r--spec/javascripts/ide/components/ide_project_tree_spec.js39
-rw-r--r--spec/javascripts/ide/components/ide_repo_tree_spec.js43
-rw-r--r--spec/javascripts/ide/components/ide_review_spec.js69
-rw-r--r--spec/javascripts/ide/components/ide_side_bar_spec.js33
-rw-r--r--spec/javascripts/ide/components/ide_spec.js9
-rw-r--r--spec/javascripts/ide/components/ide_tree_list_spec.js54
-rw-r--r--spec/javascripts/ide/components/ide_tree_spec.js34
-rw-r--r--spec/javascripts/ide/components/repo_commit_section_spec.js162
-rw-r--r--spec/javascripts/ide/components/repo_editor_spec.js27
-rw-r--r--spec/javascripts/ide/components/repo_file_spec.js37
-rw-r--r--spec/javascripts/ide/components/repo_tabs_spec.js54
-rw-r--r--spec/javascripts/ide/lib/editor_spec.js4
-rw-r--r--spec/javascripts/ide/mock_data.js15
-rw-r--r--spec/javascripts/ide/stores/actions/file_spec.js24
-rw-r--r--spec/javascripts/ide/stores/actions_spec.js43
-rw-r--r--spec/javascripts/ide/stores/getters_spec.js6
-rw-r--r--spec/javascripts/ide/stores/modules/commit/actions_spec.js30
-rw-r--r--spec/javascripts/ide/stores/mutations/file_spec.js36
-rw-r--r--spec/javascripts/ide/stores/mutations_spec.js22
-rw-r--r--spec/javascripts/pipelines/graph/action_component_spec.js2
-rw-r--r--spec/javascripts/pipelines/mock_data.js101
-rw-r--r--spec/javascripts/pipelines/stage_spec.js11
-rw-r--r--spec/javascripts/projects_dropdown/components/app_spec.js41
-rw-r--r--spec/lib/backup/repository_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/trace/chunked_io_spec.rb383
-rw-r--r--spec/lib/gitlab/ci/trace/stream_spec.rb547
-rw-r--r--spec/lib/gitlab/ci/trace_spec.rb547
-rw-r--r--spec/lib/gitlab/data_builder/wiki_page_spec.rb2
-rw-r--r--spec/lib/gitlab/git/repository_spec.rb60
-rw-r--r--spec/lib/gitlab/gitaly_client/repository_service_spec.rb11
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml2
-rw-r--r--spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb2
-rw-r--r--spec/lib/gitlab/project_search_results_spec.rb6
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb1
-rw-r--r--spec/migrations/cleanup_build_stage_migration_spec.rb53
-rw-r--r--spec/models/blob_viewer/readme_spec.rb2
-rw-r--r--spec/models/ci/build_trace_chunk_spec.rb396
-rw-r--r--spec/models/group_spec.rb89
-rw-r--r--spec/models/namespace_spec.rb15
-rw-r--r--spec/models/project_services/microsoft_teams_service_spec.rb2
-rw-r--r--spec/models/project_spec.rb79
-rw-r--r--spec/models/project_wiki_spec.rb6
-rw-r--r--spec/models/remote_mirror_spec.rb267
-rw-r--r--spec/models/repository_spec.rb37
-rw-r--r--spec/models/wiki_page_spec.rb2
-rw-r--r--spec/requests/api/runner_spec.rb51
-rw-r--r--spec/requests/api/search_spec.rb2
-rw-r--r--spec/requests/api/wikis_spec.rb34
-rw-r--r--spec/services/ci/retry_build_service_spec.rb2
-rw-r--r--spec/services/git_push_service_spec.rb66
-rw-r--r--spec/services/projects/destroy_service_spec.rb13
-rw-r--r--spec/services/projects/update_remote_mirror_service_spec.rb355
-rw-r--r--spec/services/test_hooks/project_service_spec.rb1
-rw-r--r--spec/services/wiki_pages/create_service_spec.rb2
-rw-r--r--spec/spec_helper.rb19
-rw-r--r--spec/support/chunked_io/chunked_io_helpers.rb11
-rw-r--r--spec/support/redis/redis_helpers.rb18
-rw-r--r--spec/support/shared_examples/ci_trace_shared_examples.rb741
-rw-r--r--spec/support/shared_examples/fast_destroy_all.rb38
-rw-r--r--spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb2
-rw-r--r--spec/workers/admin_email_worker_spec.rb41
-rw-r--r--spec/workers/repository_check/batch_worker_spec.rb4
-rw-r--r--spec/workers/repository_check/single_repository_worker_spec.rb79
-rw-r--r--spec/workers/repository_remove_remote_worker_spec.rb50
-rw-r--r--spec/workers/repository_update_remote_mirror_worker_spec.rb84
-rw-r--r--yarn.lock6
257 files changed, 8459 insertions, 3085 deletions
diff --git a/.flayignore b/.flayignore
index 3d69bb2c985..0c4eee10ffa 100644
--- a/.flayignore
+++ b/.flayignore
@@ -9,3 +9,4 @@ lib/gitlab/gitaly_client/operation_service.rb
lib/gitlab/background_migration/*
app/models/project_services/kubernetes_service.rb
lib/gitlab/workhorse.rb
+lib/gitlab/ci/trace/chunked_io.rb
diff --git a/.gitlab/issue_templates/Security Developer Workflow.md b/.gitlab/issue_templates/Security Developer Workflow.md
index 8dd447ed121..0c878dbf64c 100644
--- a/.gitlab/issue_templates/Security Developer Workflow.md
+++ b/.gitlab/issue_templates/Security Developer Workflow.md
@@ -28,11 +28,11 @@ Set the title to: `[Security] Description of the original issue`
- [ ] Add the ~security label and prefix with the version `WIP: [X.Y]` the title of the MR
- [ ] Make sure all MRs have a link in the [links section](#links) and are assigned to a Release Manager.
-[seckpick documentation]: https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/process.md#secpick-script
+[seckpick documentation]: https://gitlab.com/gitlab-org/release/docs/blob/master/general/security/developer.md#secpick-script
#### Documentation and final details
-- [ ] Check the topic on #security to see when the next release is going ot happen and add a link to the [links section](#links)
+- [ ] Check the topic on #security to see when the next release is going to happen and add a link to the [links section](#links)
- [ ] Find out the versions affected (the Git history of the files affected may help you with this) and add them to the [details section](#details)
- [ ] Fill in any upgrade notes that users may need to take into account in the [details section](#details)
- [ ] Add Yes/No and further details if needed to the migration and settings columns in the [details section](#details)
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index cf22efd819d..95fce8ca25f 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-0.96.2
+0.98.0
diff --git a/Gemfile b/Gemfile
index a139e8a32c4..89febc9bc0c 100644
--- a/Gemfile
+++ b/Gemfile
@@ -416,7 +416,7 @@ group :ed25519 do
end
# Gitaly GRPC client
-gem 'gitaly-proto', '~> 0.97.0', require: 'gitaly'
+gem 'gitaly-proto', '~> 0.99.0', require: 'gitaly'
gem 'grpc', '~> 1.11.0'
# Locked until https://github.com/google/protobuf/issues/4210 is closed
diff --git a/Gemfile.lock b/Gemfile.lock
index f7e2428a07f..2a63ee6a532 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -291,7 +291,7 @@ GEM
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gherkin-ruby (0.3.2)
- gitaly-proto (0.97.0)
+ gitaly-proto (0.99.0)
google-protobuf (~> 3.1)
grpc (~> 1.10)
github-linguist (5.3.3)
@@ -1060,7 +1060,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.3)
- gitaly-proto (~> 0.97.0)
+ gitaly-proto (~> 0.99.0)
github-linguist (~> 5.3.3)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-gollum-lib (~> 4.2)
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 7e9770a9ea2..9de57db48fd 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -408,7 +408,10 @@ class GfmAutoComplete {
fetchData($input, at) {
if (this.isLoadingData[at]) return;
+
this.isLoadingData[at] = true;
+ const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]];
+
if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
@@ -418,12 +421,14 @@ class GfmAutoComplete {
GfmAutoComplete.glEmojiTag = glEmojiTag;
})
.catch(() => { this.isLoadingData[at] = false; });
- } else {
- AjaxCache.retrieve(this.dataSources[GfmAutoComplete.atTypeMap[at]], true)
+ } else if (dataSource) {
+ AjaxCache.retrieve(dataSource, true)
.then((data) => {
this.loadData($input, at, data);
})
.catch(() => { this.isLoadingData[at] = false; });
+ } else {
+ this.isLoadingData[at] = false;
}
}
diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js
index 502e3569321..029fd6a67d4 100644
--- a/app/assets/javascripts/gpg_badges.js
+++ b/app/assets/javascripts/gpg_badges.js
@@ -7,12 +7,12 @@ import { __ } from '~/locale';
export default class GpgBadges {
static fetch() {
const badges = $('.js-loading-gpg-badge');
- const form = $('.commits-search-form');
+ const tag = $('.js-signature-container');
badges.html('<i class="fa fa-spinner fa-spin"></i>');
- const params = parseQueryStringIntoObject(form.serialize());
- return axios.get(form.data('signaturesPath'), { params })
+ const params = parseQueryStringIntoObject(tag.serialize());
+ return axios.get(tag.data('signaturesPath'), { params })
.then(({ data }) => {
data.signatures.forEach((signature) => {
badges.filter(`[data-commit-sha="${signature.commit_sha}"]`).replaceWith(signature.html);
diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
new file mode 100644
index 00000000000..05dbc1410de
--- /dev/null
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -0,0 +1,106 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import Icon from '~/vue_shared/components/icon.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+import { activityBarViews } from '../constants';
+
+export default {
+ components: {
+ Icon,
+ },
+ directives: {
+ tooltip,
+ },
+ computed: {
+ ...mapGetters(['currentProject', 'hasChanges']),
+ ...mapState(['currentActivityView']),
+ goBackUrl() {
+ return document.referrer || this.currentProject.web_url;
+ },
+ },
+ methods: {
+ ...mapActions(['updateActivityBarView']),
+ },
+ activityBarViews,
+};
+</script>
+
+<template>
+ <nav class="ide-activity-bar">
+ <ul class="list-unstyled">
+ <li v-once>
+ <a
+ v-tooltip
+ data-container="body"
+ data-placement="right"
+ :href="goBackUrl"
+ class="ide-sidebar-link"
+ :title="s__('IDE|Go back')"
+ :aria-label="s__('IDE|Go back')"
+ >
+ <icon
+ :size="16"
+ name="go-back"
+ />
+ </a>
+ </li>
+ <li>
+ <button
+ v-tooltip
+ data-container="body"
+ data-placement="right"
+ type="button"
+ class="ide-sidebar-link js-ide-edit-mode"
+ :class="{
+ active: currentActivityView === $options.activityBarViews.edit
+ }"
+ @click.prevent="updateActivityBarView($options.activityBarViews.edit)"
+ :title="s__('IDE|Edit')"
+ :aria-label="s__('IDE|Edit')"
+ >
+ <icon
+ name="code"
+ />
+ </button>
+ </li>
+ <li>
+ <button
+ v-tooltip
+ data-container="body"
+ data-placement="right"
+ type="button"
+ class="ide-sidebar-link js-ide-review-mode"
+ :class="{
+ active: currentActivityView === $options.activityBarViews.review
+ }"
+ @click.prevent="updateActivityBarView($options.activityBarViews.review)"
+ :title="s__('IDE|Review')"
+ :aria-label="s__('IDE|Review')"
+ >
+ <icon
+ name="file-modified"
+ />
+ </button>
+ </li>
+ <li v-show="hasChanges">
+ <button
+ v-tooltip
+ data-container="body"
+ data-placement="right"
+ type="button"
+ class="ide-sidebar-link js-ide-commit-mode"
+ :class="{
+ active: currentActivityView === $options.activityBarViews.commit
+ }"
+ @click.prevent="updateActivityBarView($options.activityBarViews.commit)"
+ :title="s__('IDE|Commit')"
+ :aria-label="s__('IDE|Commit')"
+ >
+ <icon
+ name="commit"
+ />
+ </button>
+ </li>
+ </ul>
+ </nav>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
index 45321df191c..6a5790c9dff 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -1,5 +1,5 @@
<script>
-import { mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import { sprintf, __ } from '~/locale';
import * as consts from '../../stores/modules/commit/constants';
import RadioGroup from './radio_group.vue';
@@ -9,7 +9,7 @@ export default {
RadioGroup,
},
computed: {
- ...mapState(['currentBranchId']),
+ ...mapState(['currentBranchId', 'changedFiles', 'stagedFiles']),
commitToCurrentBranchText() {
return sprintf(
__('Commit to %{branchName} branch'),
@@ -17,6 +17,17 @@ export default {
false,
);
},
+ disableMergeRequestRadio() {
+ return this.changedFiles.length > 0 && this.stagedFiles.length > 0;
+ },
+ },
+ mounted() {
+ if (this.disableMergeRequestRadio) {
+ this.updateCommitAction(consts.COMMIT_TO_CURRENT_BRANCH);
+ }
+ },
+ methods: {
+ ...mapActions('commit', ['updateCommitAction']),
},
commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH,
commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH,
@@ -44,6 +55,7 @@ export default {
:value="$options.commitToNewBranchMR"
:label="__('Create a new branch and merge request')"
:show-input="true"
+ :disabled="disableMergeRequestRadio"
/>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue
index 1f6bbca13b5..d0a60d647e5 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/empty_state.vue
@@ -1,27 +1,9 @@
<script>
-import { mapActions, mapState, mapGetters } from 'vuex';
-import Icon from '~/vue_shared/components/icon.vue';
-import tooltip from '~/vue_shared/directives/tooltip';
+import { mapState } from 'vuex';
export default {
- components: {
- Icon,
- },
- directives: {
- tooltip,
- },
- props: {
- noChangesStateSvgPath: {
- type: String,
- required: true,
- },
- },
computed: {
- ...mapState(['lastCommitMsg', 'rightPanelCollapsed', 'changedFiles', 'stagedFiles']),
- ...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']),
- },
- methods: {
- ...mapActions(['toggleRightPanelCollapsed']),
+ ...mapState(['lastCommitMsg', 'noChangesStateSvgPath']),
},
};
</script>
@@ -31,31 +13,8 @@ export default {
v-if="!lastCommitMsg"
class="multi-file-commit-panel-section ide-commit-empty-state js-empty-state"
>
- <header
- class="multi-file-commit-panel-header"
- :class="{
- 'is-collapsed': rightPanelCollapsed,
- }"
- >
- <button
- v-tooltip
- :title="collapseButtonTooltip"
- data-container="body"
- data-placement="left"
- type="button"
- class="btn btn-transparent multi-file-commit-panel-collapse-btn"
- :aria-label="__('Toggle sidebar')"
- @click.stop="toggleRightPanelCollapsed"
- >
- <icon
- :name="collapseButtonIcon"
- :size="18"
- />
- </button>
- </header>
<div
class="ide-commit-empty-state-container"
- v-if="!rightPanelCollapsed"
>
<div class="svg-content svg-80">
<img :src="noChangesStateSvgPath" />
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
new file mode 100644
index 00000000000..4a645c827ad
--- /dev/null
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -0,0 +1,171 @@
+<script>
+import { mapState, mapActions, mapGetters } from 'vuex';
+import { sprintf, __ } from '~/locale';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import CommitMessageField from './message_field.vue';
+import Actions from './actions.vue';
+import SuccessMessage from './success_message.vue';
+import { activityBarViews, MAX_WINDOW_HEIGHT_COMPACT, COMMIT_ITEM_PADDING } from '../../constants';
+
+export default {
+ components: {
+ Actions,
+ LoadingButton,
+ CommitMessageField,
+ SuccessMessage,
+ },
+ data() {
+ return {
+ isCompact: true,
+ componentHeight: null,
+ };
+ },
+ computed: {
+ ...mapState(['changedFiles', 'stagedFiles', 'currentActivityView', 'lastCommitMsg']),
+ ...mapState('commit', ['commitMessage', 'submitCommitLoading']),
+ ...mapGetters(['hasChanges']),
+ ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']),
+ overviewText() {
+ return sprintf(
+ __(
+ '<strong>%{changedFilesLength} unstaged</strong> and <strong>%{stagedFilesLength} staged</strong> changes',
+ ),
+ {
+ stagedFilesLength: this.stagedFiles.length,
+ changedFilesLength: this.changedFiles.length,
+ },
+ );
+ },
+ },
+ watch: {
+ currentActivityView() {
+ if (this.lastCommitMsg) {
+ this.isCompact = false;
+ } else {
+ this.isCompact = !(
+ this.currentActivityView === activityBarViews.commit &&
+ window.innerHeight >= MAX_WINDOW_HEIGHT_COMPACT
+ );
+ }
+ },
+ lastCommitMsg() {
+ this.isCompact =
+ this.currentActivityView !== activityBarViews.commit && this.lastCommitMsg === '';
+ },
+ },
+ methods: {
+ ...mapActions(['updateActivityBarView']),
+ ...mapActions('commit', ['updateCommitMessage', 'discardDraft', 'commitChanges']),
+ toggleIsSmall() {
+ this.updateActivityBarView(activityBarViews.commit)
+ .then(() => {
+ this.isCompact = !this.isCompact;
+ })
+ .catch(e => {
+ throw e;
+ });
+ },
+ beforeEnterTransition() {
+ const elHeight = this.isCompact
+ ? this.$refs.formEl && this.$refs.formEl.offsetHeight
+ : this.$refs.compactEl && this.$refs.compactEl.offsetHeight;
+
+ this.componentHeight = elHeight + COMMIT_ITEM_PADDING;
+ },
+ enterTransition() {
+ this.$nextTick(() => {
+ const elHeight = this.isCompact
+ ? this.$refs.compactEl && this.$refs.compactEl.offsetHeight
+ : this.$refs.formEl && this.$refs.formEl.offsetHeight;
+
+ this.componentHeight = elHeight + COMMIT_ITEM_PADDING;
+ });
+ },
+ afterEndTransition() {
+ this.componentHeight = null;
+ },
+ },
+ activityBarViews,
+};
+</script>
+
+<template>
+ <div
+ class="multi-file-commit-form"
+ :class="{
+ 'is-compact': isCompact,
+ 'is-full': !isCompact
+ }"
+ :style="{
+ height: componentHeight ? `${componentHeight}px` : null,
+ }"
+ >
+ <transition
+ name="commit-form-slide-up"
+ @before-enter="beforeEnterTransition"
+ @enter="enterTransition"
+ @after-enter="afterEndTransition"
+ >
+ <div
+ v-if="isCompact"
+ class="commit-form-compact"
+ ref="compactEl"
+ >
+ <button
+ type="button"
+ :disabled="!hasChanges"
+ class="btn btn-primary btn-sm btn-block"
+ @click="toggleIsSmall"
+ >
+ {{ __('Commit') }}
+ </button>
+ <p
+ class="text-center"
+ v-html="overviewText"
+ ></p>
+ </div>
+ <form
+ v-if="!isCompact"
+ class="form-horizontal"
+ @submit.prevent.stop="commitChanges"
+ ref="formEl"
+ >
+ <transition name="fade">
+ <success-message
+ v-show="lastCommitMsg"
+ />
+ </transition>
+ <commit-message-field
+ :text="commitMessage"
+ @input="updateCommitMessage"
+ />
+ <div class="clearfix prepend-top-15">
+ <actions />
+ <loading-button
+ :loading="submitCommitLoading"
+ :disabled="commitButtonDisabled"
+ container-class="btn btn-success btn-sm pull-left"
+ :label="__('Commit')"
+ @click="commitChanges"
+ />
+ <button
+ v-if="!discardDraftButtonDisabled"
+ type="button"
+ class="btn btn-default btn-sm pull-right"
+ @click="discardDraft"
+ >
+ {{ __('Discard draft') }}
+ </button>
+ <button
+ v-else
+ type="button"
+ class="btn btn-default btn-sm pull-right"
+ @click="toggleIsSmall"
+ >
+ {{ __('Collapse') }}
+ </button>
+ </div>
+ </form>
+ </transition>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
index ff05ee8682a..c3ac18bfb83 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -1,16 +1,14 @@
<script>
-import { mapActions, mapState, mapGetters } from 'vuex';
+import { mapActions } from 'vuex';
import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import ListItem from './list_item.vue';
-import ListCollapsed from './list_collapsed.vue';
export default {
components: {
Icon,
ListItem,
- ListCollapsed,
},
directives: {
tooltip,
@@ -24,11 +22,6 @@ export default {
type: Array,
required: true,
},
- showToggle: {
- type: Boolean,
- required: false,
- default: true,
- },
iconName: {
type: String,
required: true,
@@ -51,9 +44,12 @@ export default {
default: false,
},
},
+ data() {
+ return {
+ showActionButton: false,
+ };
+ },
computed: {
- ...mapState(['rightPanelCollapsed']),
- ...mapGetters(['collapseButtonIcon', 'collapseButtonTooltip']),
titleText() {
return sprintf(__('%{title} changes'), {
title: this.title,
@@ -61,10 +57,13 @@ export default {
},
},
methods: {
- ...mapActions(['toggleRightPanelCollapsed', 'stageAllChanges', 'unstageAllChanges']),
+ ...mapActions(['stageAllChanges', 'unstageAllChanges']),
actionBtnClicked() {
this[this.action]();
},
+ setShowActionButton(show) {
+ this.showActionButton = show;
+ },
},
};
</script>
@@ -72,19 +71,14 @@ export default {
<template>
<div
class="ide-commit-list-container"
- :class="{
- 'is-collapsed': rightPanelCollapsed,
- }"
>
<header
class="multi-file-commit-panel-header"
+ @mouseenter="setShowActionButton(true)"
+ @mouseleave="setShowActionButton(false)"
>
<div
- v-if="!rightPanelCollapsed"
class="multi-file-commit-panel-header-title"
- :class="{
- 'append-right-10': showToggle,
- }"
>
<icon
v-once
@@ -92,7 +86,14 @@ export default {
:size="18"
/>
{{ titleText }}
+ <span
+ v-show="!showActionButton"
+ class="ide-commit-file-count"
+ >
+ {{ fileList.length }}
+ </span>
<button
+ v-show="showActionButton"
type="button"
class="btn btn-blank btn-link ide-staged-action-btn"
@click="actionBtnClicked"
@@ -100,52 +101,28 @@ export default {
{{ actionBtnText }}
</button>
</div>
- <button
- v-if="showToggle"
- v-tooltip
- :title="collapseButtonTooltip"
- data-container="body"
- data-placement="left"
- type="button"
- class="btn btn-transparent multi-file-commit-panel-collapse-btn"
- :aria-label="__('Toggle sidebar')"
- @click.stop="toggleRightPanelCollapsed"
- >
- <icon
- :name="collapseButtonIcon"
- :size="18"
- />
- </button>
</header>
- <list-collapsed
- v-if="rightPanelCollapsed"
- :files="fileList"
- :icon-name="iconName"
- :title="title"
- />
- <template v-else>
- <ul
- v-if="fileList.length"
- class="multi-file-commit-list list-unstyled append-bottom-0"
- >
- <li
- v-for="file in fileList"
- :key="file.key"
- >
- <list-item
- :file="file"
- :action-component="itemActionComponent"
- :key-prefix="title"
- :staged-list="stagedList"
- />
- </li>
- </ul>
- <p
- v-else
- class="multi-file-commit-list help-block"
+ <ul
+ v-if="fileList.length"
+ class="multi-file-commit-list list-unstyled append-bottom-0"
+ >
+ <li
+ v-for="file in fileList"
+ :key="file.key"
>
- {{ __('No changes') }}
- </p>
- </template>
+ <list-item
+ :file="file"
+ :action-component="itemActionComponent"
+ :key-prefix="title"
+ :staged-list="stagedList"
+ />
+ </li>
+ </ul>
+ <p
+ v-else
+ class="multi-file-commit-list help-block"
+ >
+ {{ __('No changes') }}
+ </p>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
index 872302840e2..03f3e4de83c 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -3,6 +3,7 @@ import { mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import StageButton from './stage_button.vue';
import UnstageButton from './unstage_button.vue';
+import { viewerTypes } from '../../constants';
export default {
components: {
@@ -53,7 +54,7 @@ export default {
keyPrefix: this.keyPrefix.toLowerCase(),
}).then(changeViewer => {
if (changeViewer) {
- this.updateViewer('diff');
+ this.updateViewer(viewerTypes.diff);
}
});
},
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
index b660a2961cb..00f2312ae51 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
@@ -1,5 +1,6 @@
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
+import { __ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
@@ -26,10 +27,20 @@ export default {
required: false,
default: false,
},
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
...mapState('commit', ['commitAction']),
...mapGetters('commit', ['newBranchName']),
+ tooltipTitle() {
+ return this.disabled
+ ? __('This option is disabled while you still have unstaged changes')
+ : '';
+ },
},
methods: {
...mapActions('commit', ['updateCommitAction', 'updateBranchName']),
@@ -39,19 +50,28 @@ export default {
<template>
<fieldset>
- <label>
+ <label
+ v-tooltip
+ :title="tooltipTitle"
+ :class="{
+ 'is-disabled': disabled
+ }"
+ >
<input
type="radio"
name="commit-action"
:value="value"
@change="updateCommitAction($event.target.value)"
- :checked="checked"
- v-once
+ :checked="commitAction === value"
+ :disabled="disabled"
/>
<span class="prepend-left-10">
- <template v-if="label">
+ <span
+ v-if="label"
+ class="ide-radio-label"
+ >
{{ label }}
- </template>
+ </span>
<slot v-else></slot>
</span>
</label>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
index 628a17eddca..a6df91b79c2 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
@@ -2,14 +2,8 @@
import { mapState } from 'vuex';
export default {
- props: {
- committedStateSvgPath: {
- type: String,
- required: true,
- },
- },
computed: {
- ...mapState(['lastCommitMsg']),
+ ...mapState(['lastCommitMsg', 'committedStateSvgPath']),
},
};
</script>
diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
index 0c44a755f56..b9af4d27145 100644
--- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
+++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
@@ -1,28 +1,15 @@
<script>
-import Icon from '~/vue_shared/components/icon.vue';
import { __, sprintf } from '~/locale';
+import { viewerTypes } from '../constants';
export default {
- components: {
- Icon,
- },
props: {
- hasChanges: {
- type: Boolean,
- required: false,
- default: false,
- },
- mergeRequestId: {
- type: String,
- required: false,
- default: '',
- },
viewer: {
type: String,
required: true,
},
- showShadow: {
- type: Boolean,
+ mergeRequestId: {
+ type: Number,
required: true,
},
},
@@ -38,84 +25,45 @@ export default {
this.$emit('click', mode);
},
},
+ viewerTypes,
};
</script>
<template>
<div
class="dropdown"
- :class="{
- shadow: showShadow,
- }"
>
<button
type="button"
- class="btn btn-primary btn-sm"
- :class="{
- 'btn-inverted': hasChanges,
- }"
+ class="btn btn-link"
data-toggle="dropdown"
>
- <template v-if="viewer === 'mrdiff' && mergeRequestId">
- {{ mergeReviewLine }}
- </template>
- <template v-else-if="viewer === 'editor'">
- {{ __('Editing') }}
- </template>
- <template v-else>
- {{ __('Reviewing') }}
- </template>
- <icon
- name="angle-down"
- :size="12"
- css-classes="caret-down"
- />
+ {{ __('Edit') }}
</button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-open-left">
<ul>
- <template v-if="mergeRequestId">
- <li>
- <a
- href="#"
- @click.prevent="changeMode('mrdiff')"
- :class="{
- 'is-active': viewer === 'mrdiff',
- }"
- >
- <strong class="dropdown-menu-inner-title">
- {{ mergeReviewLine }}
- </strong>
- <span class="dropdown-menu-inner-content">
- {{ __('Compare changes with the merge request target branch') }}
- </span>
- </a>
- </li>
- <li
- role="separator"
- class="divider"
- >
- </li>
- </template>
<li>
<a
href="#"
- @click.prevent="changeMode('editor')"
+ @click.prevent="changeMode($options.viewerTypes.mr)"
:class="{
- 'is-active': viewer === 'editor',
+ 'is-active': viewer === $options.viewerTypes.mr,
}"
>
- <strong class="dropdown-menu-inner-title">{{ __('Editing') }}</strong>
+ <strong class="dropdown-menu-inner-title">
+ {{ mergeReviewLine }}
+ </strong>
<span class="dropdown-menu-inner-content">
- {{ __('View and edit lines') }}
+ {{ __('Compare changes with the merge request target branch') }}
</span>
</a>
</li>
<li>
<a
href="#"
- @click.prevent="changeMode('diff')"
+ @click.prevent="changeMode($options.viewerTypes.diff)"
:class="{
- 'is-active': viewer === 'diff',
+ 'is-active': viewer === $options.viewerTypes.diff,
}"
>
<strong class="dropdown-menu-inner-title">{{ __('Reviewing') }}</strong>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 0274fc7d299..77b35078e56 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,82 +1,67 @@
<script>
- import { mapActions, mapState, mapGetters } from 'vuex';
- import Mousetrap from 'mousetrap';
- import ideSidebar from './ide_side_bar.vue';
- import ideContextbar from './ide_context_bar.vue';
- import repoTabs from './repo_tabs.vue';
- import ideStatusBar from './ide_status_bar.vue';
- import repoEditor from './repo_editor.vue';
- import FindFile from './file_finder/index.vue';
+import Mousetrap from 'mousetrap';
+import { mapActions, mapState, mapGetters } from 'vuex';
+import IdeSidebar from './ide_side_bar.vue';
+import RepoTabs from './repo_tabs.vue';
+import IdeStatusBar from './ide_status_bar.vue';
+import RepoEditor from './repo_editor.vue';
+import FindFile from './file_finder/index.vue';
- const originalStopCallback = Mousetrap.stopCallback;
+const originalStopCallback = Mousetrap.stopCallback;
- export default {
- components: {
- ideSidebar,
- ideContextbar,
- repoTabs,
- ideStatusBar,
- repoEditor,
- FindFile,
- },
- props: {
- emptyStateSvgPath: {
- type: String,
- required: true,
- },
- noChangesStateSvgPath: {
- type: String,
- required: true,
- },
- committedStateSvgPath: {
- type: String,
- required: true,
- },
- },
- computed: {
- ...mapState([
- 'changedFiles',
- 'openFiles',
- 'viewer',
- 'currentMergeRequestId',
- 'fileFindVisible',
- ]),
- ...mapGetters(['activeFile', 'hasChanges']),
- },
- mounted() {
- const returnValue = 'Are you sure you want to lose unsaved changes?';
- window.onbeforeunload = e => {
- if (!this.changedFiles.length) return undefined;
+export default {
+ components: {
+ IdeSidebar,
+ RepoTabs,
+ IdeStatusBar,
+ RepoEditor,
+ FindFile,
+ },
+ computed: {
+ ...mapState([
+ 'changedFiles',
+ 'openFiles',
+ 'viewer',
+ 'currentMergeRequestId',
+ 'fileFindVisible',
+ 'emptyStateSvgPath',
+ ]),
+ ...mapGetters(['activeFile', 'hasChanges']),
+ },
+ mounted() {
+ const returnValue = 'Are you sure you want to lose unsaved changes?';
+ window.onbeforeunload = e => {
+ if (!this.changedFiles.length) return undefined;
- Object.assign(e, {
- returnValue,
- });
- return returnValue;
- };
+ Object.assign(e, {
+ returnValue,
+ });
+ return returnValue;
+ };
- Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => {
- if (e.preventDefault) {
- e.preventDefault();
- }
+ Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => {
+ if (e.preventDefault) {
+ e.preventDefault();
+ }
- this.toggleFileFinder(!this.fileFindVisible);
- });
+ this.toggleFileFinder(!this.fileFindVisible);
+ });
- Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo);
- },
- methods: {
- ...mapActions(['toggleFileFinder']),
- mousetrapStopCallback(e, el, combo) {
- if (combo === 't' && el.classList.contains('dropdown-input-field')) {
- return true;
- } else if (combo === 'command+p' || combo === 'ctrl+p') {
- return false;
- }
+ Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo);
+ },
+ methods: {
+ ...mapActions(['toggleFileFinder']),
+ mousetrapStopCallback(e, el, combo) {
+ if (combo === 't' && el.classList.contains('dropdown-input-field')) {
+ return true;
+ } else if (combo === 'command+p' || combo === 'ctrl+p') {
+ return false;
+ }
- return originalStopCallback(e, el, combo);
- },
+ return originalStopCallback(e, el, combo);
},
- };
+ },
+};
</script>
<template>
@@ -136,9 +121,5 @@
</div>
</template>
</div>
- <ide-contextbar
- :no-changes-state-svg-path="noChangesStateSvgPath"
- :committed-state-svg-path="committedStateSvgPath"
- />
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_context_bar.vue b/app/assets/javascripts/ide/components/ide_context_bar.vue
deleted file mode 100644
index 627fbeb9adf..00000000000
--- a/app/assets/javascripts/ide/components/ide_context_bar.vue
+++ /dev/null
@@ -1,42 +0,0 @@
-<script>
-import icon from '~/vue_shared/components/icon.vue';
-import panelResizer from '~/vue_shared/components/panel_resizer.vue';
-import repoCommitSection from './repo_commit_section.vue';
-import ResizablePanel from './resizable_panel.vue';
-
-export default {
- components: {
- repoCommitSection,
- icon,
- panelResizer,
- ResizablePanel,
- },
- props: {
- noChangesStateSvgPath: {
- type: String,
- required: true,
- },
- committedStateSvgPath: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <resizable-panel
- :collapsible="true"
- :initial-width="340"
- side="right"
- >
- <div
- class="multi-file-commit-panel-section"
- >
- <repo-commit-section
- :no-changes-state-svg-path="noChangesStateSvgPath"
- :committed-state-svg-path="committedStateSvgPath"
- />
- </div>
- </resizable-panel>
-</template>
diff --git a/app/assets/javascripts/ide/components/ide_external_links.vue b/app/assets/javascripts/ide/components/ide_external_links.vue
deleted file mode 100644
index c6f6e0d2348..00000000000
--- a/app/assets/javascripts/ide/components/ide_external_links.vue
+++ /dev/null
@@ -1,43 +0,0 @@
-<script>
-import icon from '~/vue_shared/components/icon.vue';
-
-export default {
- components: {
- icon,
- },
- props: {
- projectUrl: {
- type: String,
- required: true,
- },
- },
- computed: {
- goBackUrl() {
- return document.referrer || this.projectUrl;
- },
- },
-};
-</script>
-
-<template>
- <nav
- class="ide-external-links"
- v-once
- >
- <p>
- <a
- :href="goBackUrl"
- class="ide-sidebar-link"
- >
- <icon
- :size="16"
- class="append-right-8"
- name="go-back"
- />
- <span class="ide-external-links-text">
- {{ s__('Go back') }}
- </span>
- </a>
- </p>
- </nav>
-</template>
diff --git a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue b/app/assets/javascripts/ide/components/ide_project_branches_tree.vue
deleted file mode 100644
index eb2749e6151..00000000000
--- a/app/assets/javascripts/ide/components/ide_project_branches_tree.vue
+++ /dev/null
@@ -1,47 +0,0 @@
-<script>
- import icon from '~/vue_shared/components/icon.vue';
- import repoTree from './ide_repo_tree.vue';
- import newDropdown from './new_dropdown/index.vue';
-
- export default {
- components: {
- repoTree,
- icon,
- newDropdown,
- },
- props: {
- projectId: {
- type: String,
- required: true,
- },
- branch: {
- type: Object,
- required: true,
- },
- },
- };
-</script>
-
-<template>
- <div class="branch-container">
- <div class="branch-header">
- <div class="branch-header-title str-truncated ref-name">
- <icon
- name="branch"
- :size="12"
- />
- {{ branch.name }}
- </div>
- <div class="branch-header-btns">
- <new-dropdown
- :project-id="projectId"
- :branch="branch.name"
- path=""
- />
- </div>
- </div>
- <repo-tree
- :tree="branch.tree"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/ide/components/ide_project_tree.vue b/app/assets/javascripts/ide/components/ide_project_tree.vue
deleted file mode 100644
index a6f40286ac1..00000000000
--- a/app/assets/javascripts/ide/components/ide_project_tree.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<script>
-import ProjectAvatarImage from '~/vue_shared/components/project_avatar/image.vue';
-import Identicon from '../../vue_shared/components/identicon.vue';
-import BranchesTree from './ide_project_branches_tree.vue';
-import ExternalLinks from './ide_external_links.vue';
-
-export default {
- components: {
- BranchesTree,
- ExternalLinks,
- ProjectAvatarImage,
- Identicon,
- },
- props: {
- project: {
- type: Object,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <div class="projects-sidebar">
- <div class="context-header">
- <a
- :title="project.name"
- :href="project.web_url"
- >
- <div
- v-if="project.avatar_url"
- class="avatar-container s40 project-avatar"
- >
- <project-avatar-image
- class="avatar-container project-avatar"
- :link-href="project.path"
- :img-src="project.avatar_url"
- :img-alt="project.name"
- :img-size="40"
- />
- </div>
- <identicon
- v-else
- size-class="s40"
- :entity-id="project.id"
- :entity-name="project.name"
- />
- <div class="sidebar-context-title">
- {{ project.name }}
- </div>
- </a>
- </div>
- <external-links
- :project-url="project.web_url"
- />
- <div class="multi-file-commit-panel-inner-scroll">
- <branches-tree
- v-for="branch in project.branches"
- :key="branch.name"
- :project-id="project.path_with_namespace"
- :branch="branch"
- />
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/ide/components/ide_repo_tree.vue b/app/assets/javascripts/ide/components/ide_repo_tree.vue
deleted file mode 100644
index e6af88e04bc..00000000000
--- a/app/assets/javascripts/ide/components/ide_repo_tree.vue
+++ /dev/null
@@ -1,41 +0,0 @@
-<script>
-import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
-import RepoFile from './repo_file.vue';
-
-export default {
- components: {
- RepoFile,
- SkeletonLoadingContainer,
- },
- props: {
- tree: {
- type: Object,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <div
- class="ide-file-list"
- >
- <template v-if="tree.loading">
- <div
- class="multi-file-loading-container"
- v-for="n in 3"
- :key="n"
- >
- <skeleton-loading-container />
- </div>
- </template>
- <template v-else>
- <repo-file
- v-for="file in tree.tree"
- :key="file.key"
- :file="file"
- :level="0"
- />
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue
new file mode 100644
index 00000000000..0c9ec3b00f0
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_review.vue
@@ -0,0 +1,62 @@
+<script>
+import { mapGetters, mapState, mapActions } from 'vuex';
+import IdeTreeList from './ide_tree_list.vue';
+import EditorModeDropdown from './editor_mode_dropdown.vue';
+import { viewerTypes } from '../constants';
+
+export default {
+ components: {
+ IdeTreeList,
+ EditorModeDropdown,
+ },
+ computed: {
+ ...mapGetters(['currentMergeRequest']),
+ ...mapState(['viewer']),
+ showLatestChangesText() {
+ return !this.currentMergeRequest || this.viewer === viewerTypes.diff;
+ },
+ showMergeRequestText() {
+ return this.currentMergeRequest && this.viewer === viewerTypes.mr;
+ },
+ },
+ mounted() {
+ this.$nextTick(() => {
+ this.updateViewer(this.currentMergeRequest ? viewerTypes.mr : viewerTypes.diff);
+ });
+ },
+ methods: {
+ ...mapActions(['updateViewer']),
+ },
+};
+</script>
+
+<template>
+ <ide-tree-list
+ :viewer-type="viewer"
+ header-class="ide-review-header"
+ :disable-action-dropdown="true"
+ >
+ <template
+ slot="header"
+ >
+ <div class="ide-review-button-holder">
+ {{ __('Review') }}
+ <editor-mode-dropdown
+ v-if="currentMergeRequest"
+ :viewer="viewer"
+ :merge-request-id="currentMergeRequest.iid"
+ @click="updateViewer"
+ />
+ </div>
+ <div class="prepend-top-5 ide-review-sub-header">
+ <template v-if="showLatestChangesText">
+ {{ __('Latest changes') }}
+ </template>
+ <template v-else-if="showMergeRequestText">
+ {{ __('Merge request') }}
+ (<a :href="currentMergeRequest.web_url">!{{ currentMergeRequest.iid }}</a>)
+ </template>
+ </div>
+ </template>
+ </ide-tree-list>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
index 8cf1ccb4fce..3f980203911 100644
--- a/app/assets/javascripts/ide/components/ide_side_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -1,36 +1,82 @@
<script>
- import { mapState, mapGetters } from 'vuex';
- import icon from '~/vue_shared/components/icon.vue';
- import panelResizer from '~/vue_shared/components/panel_resizer.vue';
- import skeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
- import projectTree from './ide_project_tree.vue';
- import ResizablePanel from './resizable_panel.vue';
+import { mapState, mapGetters } from 'vuex';
+import ProjectAvatarImage from '~/vue_shared/components/project_avatar/image.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+import tooltip from '~/vue_shared/directives/tooltip';
+import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
+import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+import Identicon from '../../vue_shared/components/identicon.vue';
+import IdeTree from './ide_tree.vue';
+import ResizablePanel from './resizable_panel.vue';
+import ActivityBar from './activity_bar.vue';
+import CommitSection from './repo_commit_section.vue';
+import CommitForm from './commit_sidebar/form.vue';
+import IdeReview from './ide_review.vue';
+import SuccessMessage from './commit_sidebar/success_message.vue';
+import { activityBarViews } from '../constants';
- export default {
- components: {
- projectTree,
- icon,
- panelResizer,
- skeletonLoadingContainer,
- ResizablePanel,
+export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ Icon,
+ PanelResizer,
+ SkeletonLoadingContainer,
+ ResizablePanel,
+ ActivityBar,
+ ProjectAvatarImage,
+ Identicon,
+ CommitSection,
+ IdeTree,
+ CommitForm,
+ IdeReview,
+ SuccessMessage,
+ },
+ data() {
+ return {
+ showTooltip: false,
+ };
+ },
+ computed: {
+ ...mapState([
+ 'loading',
+ 'currentBranchId',
+ 'currentActivityView',
+ 'changedFiles',
+ 'stagedFiles',
+ 'lastCommitMsg',
+ ]),
+ ...mapGetters(['currentProject', 'someUncommitedChanges']),
+ showSuccessMessage() {
+ return (
+ this.currentActivityView === activityBarViews.edit &&
+ (this.lastCommitMsg && !this.someUncommitedChanges)
+ );
},
- computed: {
- ...mapState([
- 'loading',
- ]),
- ...mapGetters([
- 'projectsWithTrees',
- ]),
+ branchTooltipTitle() {
+ return this.showTooltip ? this.currentBranchId : undefined;
},
- };
+ },
+ watch: {
+ currentBranchId() {
+ this.$nextTick(() => {
+ this.showTooltip = this.$refs.branchId.scrollWidth > this.$refs.branchId.offsetWidth;
+ });
+ },
+ },
+};
</script>
<template>
<resizable-panel
:collapsible="false"
- :initial-width="290"
+ :initial-width="340"
side="left"
>
+ <activity-bar
+ v-if="!loading"
+ />
<div class="multi-file-commit-panel-inner">
<template v-if="loading">
<div
@@ -41,11 +87,54 @@
<skeleton-loading-container />
</div>
</template>
- <project-tree
- v-for="project in projectsWithTrees"
- :key="project.id"
- :project="project"
- />
+ <template v-else>
+ <div class="context-header ide-context-header">
+ <a
+ :href="currentProject.web_url"
+ >
+ <div
+ v-if="currentProject.avatar_url"
+ class="avatar-container s40 project-avatar"
+ >
+ <project-avatar-image
+ class="avatar-container project-avatar"
+ :link-href="currentProject.path"
+ :img-src="currentProject.avatar_url"
+ :img-alt="currentProject.name"
+ :img-size="40"
+ />
+ </div>
+ <identicon
+ v-else
+ size-class="s40"
+ :entity-id="currentProject.id"
+ :entity-name="currentProject.name"
+ />
+ <div class="ide-sidebar-project-title">
+ <div class="sidebar-context-title">
+ {{ currentProject.name }}
+ </div>
+ <div
+ class="sidebar-context-title ide-sidebar-branch-title"
+ ref="branchId"
+ v-tooltip
+ :title="branchTooltipTitle"
+ >
+ <icon
+ name="branch"
+ css-classes="append-right-5"
+ />{{ currentBranchId }}
+ </div>
+ </div>
+ </a>
+ </div>
+ <div class="multi-file-commit-panel-inner-scroll">
+ <component
+ :is="currentActivityView"
+ />
+ </div>
+ <commit-form />
+ </template>
</div>
</resizable-panel>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue
new file mode 100644
index 00000000000..8fc4ebe6ca6
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_tree.vue
@@ -0,0 +1,42 @@
+<script>
+import { mapState, mapGetters, mapActions } from 'vuex';
+import NewDropdown from './new_dropdown/index.vue';
+import IdeTreeList from './ide_tree_list.vue';
+
+export default {
+ components: {
+ NewDropdown,
+ IdeTreeList,
+ },
+ computed: {
+ ...mapState(['currentBranchId']),
+ ...mapGetters(['currentProject', 'currentTree', 'activeFile']),
+ },
+ mounted() {
+ if (this.activeFile && this.activeFile.pending) {
+ this.$router.push(`/project${this.activeFile.url}`, () => {
+ this.updateViewer('editor');
+ });
+ }
+ },
+ methods: {
+ ...mapActions(['updateViewer']),
+ },
+};
+</script>
+
+<template>
+ <ide-tree-list
+ viewer-type="editor"
+ >
+ <template
+ slot="header"
+ >
+ {{ __('Edit') }}
+ <new-dropdown
+ :project-id="currentProject.name_with_namespace"
+ :branch="currentBranchId"
+ />
+ </template>
+ </ide-tree-list>
+</template>
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
new file mode 100644
index 00000000000..e64a09fcc90
--- /dev/null
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -0,0 +1,76 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import Icon from '~/vue_shared/components/icon.vue';
+import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
+import RepoFile from './repo_file.vue';
+import NewDropdown from './new_dropdown/index.vue';
+
+export default {
+ components: {
+ Icon,
+ RepoFile,
+ SkeletonLoadingContainer,
+ NewDropdown,
+ },
+ props: {
+ viewerType: {
+ type: String,
+ required: true,
+ },
+ headerClass: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ disableActionDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapState(['currentBranchId']),
+ ...mapGetters(['currentProject', 'currentTree']),
+ showLoading() {
+ return !this.currentTree || this.currentTree.loading;
+ },
+ },
+ mounted() {
+ this.updateViewer(this.viewerType);
+ },
+ methods: {
+ ...mapActions(['updateViewer']),
+ },
+};
+</script>
+
+<template>
+ <div
+ class="ide-file-list"
+ >
+ <template v-if="showLoading">
+ <div
+ class="multi-file-loading-container"
+ v-for="n in 3"
+ :key="n"
+ >
+ <skeleton-loading-container />
+ </div>
+ </template>
+ <template v-else>
+ <header
+ class="ide-tree-header"
+ :class="headerClass"
+ >
+ <slot name="header"></slot>
+ </header>
+ <repo-file
+ v-for="file in currentTree.tree"
+ :key="file.key"
+ :file="file"
+ :level="0"
+ :disable-action-dropdown="disableActionDropdown"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index b1b5c0d4a28..a0ce1c9dac7 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -17,7 +17,8 @@ export default {
},
path: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
},
data() {
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index fa929381744..c5092d8e04d 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -3,13 +3,10 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
import CommitFilesList from './commit_sidebar/list.vue';
import EmptyState from './commit_sidebar/empty_state.vue';
-import CommitMessageField from './commit_sidebar/message_field.vue';
-import SuccessMessage from './commit_sidebar/success_message.vue';
import * as consts from '../stores/modules/commit/constants';
-import Actions from './commit_sidebar/actions.vue';
+import { activityBarViews } from '../constants';
export default {
components: {
@@ -17,42 +14,50 @@ export default {
Icon,
CommitFilesList,
EmptyState,
- SuccessMessage,
- Actions,
- LoadingButton,
- CommitMessageField,
},
directives: {
tooltip,
},
- props: {
- noChangesStateSvgPath: {
- type: String,
- required: true,
- },
- committedStateSvgPath: {
- type: String,
- required: true,
- },
- },
computed: {
+ ...mapState([
+ 'changedFiles',
+ 'stagedFiles',
+ 'rightPanelCollapsed',
+ 'lastCommitMsg',
+ 'unusedSeal',
+ ]),
+ ...mapState('commit', ['commitMessage', 'submitCommitLoading']),
+ ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges']),
+ ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']),
showStageUnstageArea() {
return !!(this.someUncommitedChanges || this.lastCommitMsg || !this.unusedSeal);
},
- someUncommitedChanges() {
- return !!(this.changedFiles.length || this.stagedFiles.length);
+ },
+ watch: {
+ hasChanges() {
+ if (!this.hasChanges) {
+ this.updateActivityBarView(activityBarViews.edit);
+ }
},
- ...mapState(['changedFiles', 'stagedFiles', 'rightPanelCollapsed', 'lastCommitMsg', 'unusedSeal']),
- ...mapState('commit', ['commitMessage', 'submitCommitLoading']),
- ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']),
+ },
+ mounted() {
+ if (this.lastOpenedFile) {
+ this.openPendingTab({
+ file: this.lastOpenedFile,
+ })
+ .then(changeViewer => {
+ if (changeViewer) {
+ this.updateViewer('diff');
+ }
+ })
+ .catch(e => {
+ throw e;
+ });
+ }
},
methods: {
- ...mapActions('commit', [
- 'updateCommitMessage',
- 'discardDraft',
- 'commitChanges',
- 'updateCommitAction',
- ]),
+ ...mapActions(['openPendingTab', 'updateViewer', 'updateActivityBarView']),
+ ...mapActions('commit', ['commitChanges', 'updateCommitAction']),
forceCreateNewBranch() {
return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges());
},
@@ -80,6 +85,7 @@ export default {
v-if="showStageUnstageArea"
>
<commit-files-list
+ class="is-first"
icon-name="unstaged"
:title="__('Unstaged')"
:file-list="changedFiles"
@@ -94,49 +100,11 @@ export default {
action="unstageAllChanges"
:action-btn-text="__('Unstage all')"
item-action-component="unstage-button"
- :show-toggle="false"
:staged-list="true"
/>
</template>
<empty-state
v-if="unusedSeal"
- :no-changes-state-svg-path="noChangesStateSvgPath"
/>
- <div
- class="multi-file-commit-panel-bottom"
- >
- <form
- class="form-horizontal multi-file-commit-form"
- @submit.prevent.stop="commitChanges"
- v-if="!rightPanelCollapsed"
- >
- <success-message
- v-if="lastCommitMsg && !someUncommitedChanges"
- :committed-state-svg-path="committedStateSvgPath"
- />
- <commit-message-field
- :text="commitMessage"
- @input="updateCommitMessage"
- />
- <div class="clearfix prepend-top-15">
- <actions />
- <loading-button
- :loading="submitCommitLoading"
- :disabled="commitButtonDisabled"
- container-class="btn btn-success btn-sm pull-left"
- :label="__('Commit')"
- @click="commitChanges"
- />
- <button
- v-if="!discardDraftButtonDisabled"
- type="button"
- class="btn btn-default btn-sm pull-right"
- @click="discardDraft"
- >
- {{ __('Discard draft') }}
- </button>
- </div>
- </form>
- </div>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 3a04cdd8e46..ff7e546fb9c 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -3,6 +3,7 @@
import { mapState, mapGetters, mapActions } from 'vuex';
import flash from '~/flash';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
+import { activityBarViews, viewerTypes } from '../constants';
import monacoLoader from '../monaco_loader';
import Editor from '../lib/editor';
import IdeFileButtons from './ide_file_buttons.vue';
@@ -19,8 +20,14 @@ export default {
},
},
computed: {
- ...mapState(['rightPanelCollapsed', 'viewer', 'delayViewerUpdated', 'panelResizing']),
- ...mapGetters(['currentMergeRequest', 'getStagedFile']),
+ ...mapState(['rightPanelCollapsed', 'viewer', 'panelResizing', 'currentActivityView']),
+ ...mapGetters([
+ 'currentMergeRequest',
+ 'getStagedFile',
+ 'isEditModeActive',
+ 'isCommitModeActive',
+ 'isReviewModeActive',
+ ]),
shouldHideEditor() {
return this.file && this.file.binary && !this.file.content;
},
@@ -40,6 +47,21 @@ export default {
// Compare key to allow for files opened in review mode to be cached differently
if (newVal.key !== this.file.key) {
this.initMonaco();
+
+ if (this.currentActivityView !== activityBarViews.edit) {
+ this.setFileViewMode({
+ file: this.file,
+ viewMode: 'edit',
+ });
+ }
+ }
+ },
+ currentActivityView() {
+ if (this.currentActivityView !== activityBarViews.edit) {
+ this.setFileViewMode({
+ file: this.file,
+ viewMode: 'edit',
+ });
}
},
rightPanelCollapsed() {
@@ -77,7 +99,6 @@ export default {
'setFileViewMode',
'setFileEOL',
'updateViewer',
- 'updateDelayViewerUpdated',
]),
initMonaco() {
if (this.shouldHideEditor) return;
@@ -89,14 +110,6 @@ export default {
baseSha: this.currentMergeRequest ? this.currentMergeRequest.baseCommitSha : '',
})
.then(() => {
- const viewerPromise = this.delayViewerUpdated
- ? this.updateViewer(this.file.pending ? 'diff' : 'editor')
- : Promise.resolve();
-
- return viewerPromise;
- })
- .then(() => {
- this.updateDelayViewerUpdated(false);
this.createEditorInstance();
})
.catch(err => {
@@ -108,10 +121,10 @@ export default {
this.editor.dispose();
this.$nextTick(() => {
- if (this.viewer === 'editor') {
+ if (this.viewer === viewerTypes.edit) {
this.editor.createInstance(this.$refs.editor);
} else {
- this.editor.createDiffInstance(this.$refs.editor);
+ this.editor.createDiffInstance(this.$refs.editor, !this.isReviewModeActive);
}
this.setupEditor();
@@ -127,7 +140,7 @@ export default {
this.file.staged && this.file.key.indexOf('unstaged-') === 0 ? head : null,
);
- if (this.viewer === 'mrdiff') {
+ if (this.viewer === viewerTypes.mr) {
this.editor.attachMergeRequestModel(this.model);
} else {
this.editor.attachModel(this.model);
@@ -168,6 +181,7 @@ export default {
});
},
},
+ viewerTypes,
};
</script>
@@ -176,16 +190,17 @@ export default {
id="ide"
class="blob-viewer-container blob-editor-container"
>
- <div class="ide-mode-tabs clearfix">
+ <div class="ide-mode-tabs clearfix" >
<ul
class="nav-links pull-left"
- v-if="!shouldHideEditor">
+ v-if="!shouldHideEditor && isEditModeActive"
+ >
<li :class="editTabCSS">
<a
href="javascript:void(0);"
role="button"
@click.prevent="setFileViewMode({ file, viewMode: 'edit' })">
- <template v-if="viewer === 'editor'">
+ <template v-if="viewer === $options.viewerTypes.edit">
{{ __('Edit') }}
</template>
<template v-else>
@@ -212,6 +227,9 @@ export default {
v-show="!shouldHideEditor && file.viewMode === 'edit'"
ref="editor"
class="multi-file-editor-holder"
+ :class="{
+ 'is-readonly': isCommitModeActive,
+ }"
>
</div>
<content-viewer
diff --git a/app/assets/javascripts/ide/components/repo_file.vue b/app/assets/javascripts/ide/components/repo_file.vue
index 89c5ce70dd3..14946f8c9fa 100644
--- a/app/assets/javascripts/ide/components/repo_file.vue
+++ b/app/assets/javascripts/ide/components/repo_file.vue
@@ -34,6 +34,11 @@ export default {
type: Number,
required: true,
},
+ disableActionDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
...mapGetters([
@@ -99,16 +104,14 @@ export default {
}
},
methods: {
- ...mapActions(['toggleTreeOpen', 'updateDelayViewerUpdated']),
+ ...mapActions(['toggleTreeOpen']),
clickFile() {
// Manual Action if a tree is selected/opened
if (this.isTree && this.$router.currentRoute.path === `/project${this.file.url}`) {
this.toggleTreeOpen(this.file.path);
}
- return this.updateDelayViewerUpdated(true).then(() => {
- router.push(`/project${this.file.url}`);
- });
+ router.push(`/project${this.file.url}`);
},
},
};
@@ -170,7 +173,7 @@ export default {
/>
</span>
<new-dropdown
- v-if="isTree"
+ v-if="isTree && !disableActionDropdown"
:project-id="file.projectId"
:branch="file.branchId"
:path="file.path"
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
index a3ee3184c19..fb26b973236 100644
--- a/app/assets/javascripts/ide/components/repo_tab.vue
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -32,6 +32,8 @@ export default {
return `Close ${this.tab.name}`;
},
showChangedIcon() {
+ if (this.tab.pending) return true;
+
return this.fileHasChanged ? !this.tabMouseOver : false;
},
fileHasChanged() {
@@ -66,15 +68,32 @@ export default {
<template>
<li
+ :class="{
+ active: tab.active
+ }"
@click="clickFile(tab)"
@mouseover="mouseOverTab"
@mouseout="mouseOutTab"
>
+ <div
+ class="multi-file-tab"
+ :title="tab.url"
+ >
+ <file-icon
+ :file-name="tab.name"
+ :size="16"
+ />
+ {{ tab.name }}
+ <file-status-icon
+ :file="tab"
+ />
+ </div>
<button
type="button"
class="multi-file-tab-close"
@click.stop.prevent="closeFile(tab)"
:aria-label="closeLabel"
+ :disabled="tab.pending"
>
<icon
v-if="!showChangedIcon"
@@ -87,22 +106,5 @@ export default {
:force-modified-icon="true"
/>
</button>
-
- <div
- class="multi-file-tab"
- :class="{
- active: tab.active
- }"
- :title="tab.url"
- >
- <file-icon
- :file-name="tab.name"
- :size="16"
- />
- {{ tab.name }}
- <file-status-icon
- :file="tab"
- />
- </div>
</li>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue
index 7bd646ba9b0..99e51097e12 100644
--- a/app/assets/javascripts/ide/components/repo_tabs.vue
+++ b/app/assets/javascripts/ide/components/repo_tabs.vue
@@ -32,16 +32,6 @@ export default {
default: '',
},
},
- data() {
- return {
- showShadow: false,
- };
- },
- updated() {
- if (!this.$refs.tabsScroller) return;
-
- this.showShadow = this.$refs.tabsScroller.scrollWidth > this.$refs.tabsScroller.offsetWidth;
- },
methods: {
...mapActions(['updateViewer', 'removePendingTab']),
openFileViewer(viewer) {
@@ -71,12 +61,5 @@ export default {
:tab="tab"
/>
</ul>
- <editor-mode
- :viewer="viewer"
- :show-shadow="showShadow"
- :has-changes="hasChanges"
- :merge-request-id="mergeRequestId"
- @click="openFileViewer"
- />
</div>
</template>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index b06da9f95d1..48d4cc43198 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -3,6 +3,22 @@ export const MAX_FILE_FINDER_RESULTS = 40;
export const FILE_FINDER_ROW_HEIGHT = 55;
export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33;
+export const MAX_WINDOW_HEIGHT_COMPACT = 750;
+
+export const COMMIT_ITEM_PADDING = 32;
+
// Commit message textarea
export const MAX_TITLE_LENGTH = 50;
export const MAX_BODY_LENGTH = 72;
+
+export const activityBarViews = {
+ edit: 'ide-tree',
+ commit: 'commit-section',
+ review: 'ide-review',
+};
+
+export const viewerTypes = {
+ mr: 'mrdiff',
+ edit: 'editor',
+ diff: 'diff',
+};
diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js
index 4a0a303d5a6..adca85dc65b 100644
--- a/app/assets/javascripts/ide/ide_router.js
+++ b/app/assets/javascripts/ide/ide_router.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import VueRouter from 'vue-router';
import flash from '~/flash';
import store from './stores';
+import { activityBarViews } from './constants';
Vue.use(VueRouter);
@@ -63,6 +64,8 @@ router.beforeEach((to, from, next) => {
const fullProjectId = `${to.params.namespace}/${to.params.project}`;
if (to.params.branch) {
+ store.dispatch('setCurrentBranchId', to.params.branch);
+
store.dispatch('getBranchData', {
projectId: fullProjectId,
branchId: to.params.branch,
@@ -99,14 +102,14 @@ router.beforeEach((to, from, next) => {
throw e;
});
} else if (to.params.mrid) {
- store.dispatch('updateViewer', 'mrdiff');
-
store
.dispatch('getMergeRequestData', {
projectId: fullProjectId,
mergeRequestId: to.params.mrid,
})
.then(mr => {
+ store.dispatch('updateActivityBarView', activityBarViews.review);
+
store.dispatch('getBranchData', {
projectId: fullProjectId,
branchId: mr.source_branch,
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index cbfb3dc54f2..e5c47d09886 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -14,15 +14,16 @@ function initIde(el) {
components: {
ide,
},
- render(createElement) {
- return createElement('ide', {
- props: {
- emptyStateSvgPath: el.dataset.emptyStateSvgPath,
- noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
- committedStateSvgPath: el.dataset.committedStateSvgPath,
- },
+ created() {
+ this.$store.dispatch('setEmptyStateSvgs', {
+ emptyStateSvgPath: el.dataset.emptyStateSvgPath,
+ noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
+ committedStateSvgPath: el.dataset.committedStateSvgPath,
});
},
+ render(createElement) {
+ return createElement('ide');
+ },
});
}
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
index b65d9c68a0b..9c3bb9cc17d 100644
--- a/app/assets/javascripts/ide/lib/editor.js
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -61,19 +61,19 @@ export default class Editor {
}
}
- createDiffInstance(domElement) {
+ createDiffInstance(domElement, readOnly = true) {
if (!this.instance) {
clearDomElement(domElement);
this.disposable.add(
(this.instance = this.monaco.editor.createDiffEditor(domElement, {
...defaultEditorOptions,
- readOnly: true,
quickSuggestions: false,
occurrencesHighlight: false,
- renderLineHighlight: 'none',
- hideCursorInOverviewRuler: true,
renderSideBySide: Editor.renderSideBySide(domElement),
+ readOnly,
+ renderLineHighlight: readOnly ? 'all' : 'none',
+ hideCursorInOverviewRuler: !readOnly,
})),
);
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 7358dd9ef92..1a98b42761e 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -123,6 +123,8 @@ export const scrollToTab = () => {
};
export const stageAllChanges = ({ state, commit }) => {
+ commit(types.SET_LAST_COMMIT_MSG, '');
+
state.changedFiles.forEach(file => commit(types.STAGE_CHANGE, file.path));
};
@@ -138,6 +140,18 @@ export const updateDelayViewerUpdated = ({ commit }, delay) => {
commit(types.UPDATE_DELAY_VIEWER_CHANGE, delay);
};
+export const updateActivityBarView = ({ commit }, view) => {
+ commit(types.UPDATE_ACTIVITY_BAR_VIEW, view);
+};
+
+export const setEmptyStateSvgs = ({ commit }, svgs) => {
+ commit(types.SET_EMPTY_STATE_SVGS, svgs);
+};
+
+export const setCurrentBranchId = ({ commit }, currentBranchId) => {
+ commit(types.SET_CURRENT_BRANCH, currentBranchId);
+};
+
export const updateTempFlagForEntry = ({ commit, dispatch, state }, { file, tempFile }) => {
commit(types.UPDATE_TEMP_FLAG, { path: file.path, tempFile });
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index 861830badee..3ac9b9222ca 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -5,6 +5,7 @@ import service from '../../services';
import * as types from '../mutation_types';
import router from '../../ide_router';
import { setPageTitle } from '../utils';
+import { viewerTypes } from '../../constants';
export const closeFile = ({ commit, state, dispatch }, file) => {
const path = file.path;
@@ -23,13 +24,12 @@ export const closeFile = ({ commit, state, dispatch }, file) => {
const nextFileToOpen = state.openFiles[nextIndexToOpen];
if (nextFileToOpen.pending) {
- dispatch('updateViewer', 'diff');
+ dispatch('updateViewer', viewerTypes.diff);
dispatch('openPendingTab', {
file: nextFileToOpen,
keyPrefix: nextFileToOpen.staged ? 'staged' : 'unstaged',
});
} else {
- dispatch('updateDelayViewerUpdated', true);
router.push(`/project${nextFileToOpen.url}`);
}
} else if (!state.openFiles.length) {
@@ -184,6 +184,7 @@ export const stageChange = ({ commit, state }, path) => {
const stagedFile = state.stagedFiles.find(f => f.path === path);
commit(types.STAGE_CHANGE, path);
+ commit(types.SET_LAST_COMMIT_MSG, '');
if (stagedFile) {
eventHub.$emit(`editor.update.model.new.content.staged-${stagedFile.key}`, stagedFile.content);
@@ -195,9 +196,7 @@ export const unstageChange = ({ commit }, path) => {
};
export const openPendingTab = ({ commit, getters, dispatch, state }, { file, keyPrefix }) => {
- if (getters.activeFile && getters.activeFile === file && state.viewer === 'diff') {
- return false;
- }
+ state.openFiles.forEach(f => eventHub.$emit(`editor.update.model.dispose.${f.key}`));
commit(types.ADD_PENDING_TAB, { file, keyPrefix });
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index 4eb23b2ee0f..971a15a4036 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -55,7 +55,6 @@ export const getBranchData = (
branch: data,
});
commit(types.SET_BRANCH_WORKING_REFERENCE, { projectId, branchId, reference: id });
- commit(types.SET_CURRENT_BRANCH, branchId);
resolve(data);
})
.catch(() => {
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index a93d29fd865..75286ed0cd2 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -1,5 +1,5 @@
-import { __ } from '~/locale';
import { getChangesCountForFiles, filePathMatches } from './utils';
+import { activityBarViews } from '../constants';
export const activeFile = state => state.openFiles.find(file => file.active) || null;
@@ -31,15 +31,12 @@ export const currentMergeRequest = state => {
return null;
};
-// eslint-disable-next-line no-confusing-arrow
-export const collapseButtonIcon = state =>
- state.rightPanelCollapsed ? 'angle-double-left' : 'angle-double-right';
+export const currentProject = state => state.projects[state.currentProjectId];
-export const hasChanges = state => !!state.changedFiles.length || !!state.stagedFiles.length;
+export const currentTree = state =>
+ state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
-// eslint-disable-next-line no-confusing-arrow
-export const collapseButtonTooltip = state =>
- state.rightPanelCollapsed ? __('Expand sidebar') : __('Collapse sidebar');
+export const hasChanges = state => !!state.changedFiles.length || !!state.stagedFiles.length;
export const hasMergeRequest = state => !!state.currentMergeRequestId;
@@ -59,6 +56,16 @@ export const allBlobs = state =>
export const getChangedFile = state => path => state.changedFiles.find(f => f.path === path);
export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path);
+export const lastOpenedFile = state =>
+ [...state.changedFiles, ...state.stagedFiles].sort((a, b) => b.lastOpenedAt - a.lastOpenedAt)[0];
+
+export const isEditModeActive = state => state.currentActivityView === activityBarViews.edit;
+export const isCommitModeActive = state => state.currentActivityView === activityBarViews.commit;
+export const isReviewModeActive = state => state.currentActivityView === activityBarViews.review;
+
+export const someUncommitedChanges = state =>
+ !!(state.changedFiles.length || state.stagedFiles.length);
+
export const getChangesInFolder = state => path => {
const changedFilesCount = state.changedFiles.filter(f => filePathMatches(f, path)).length;
const stagedFilesCount = state.stagedFiles.filter(
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index 4fbc97d053e..ab00d12089d 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -8,6 +8,7 @@ import router from '../../../ide_router';
import service from '../../../services';
import * as types from './mutation_types';
import * as consts from './constants';
+import { activityBarViews } from '../../../constants';
import eventHub from '../../../eventhub';
export const updateCommitMessage = ({ commit }, message) => {
@@ -75,7 +76,7 @@ export const checkCommitStatus = ({ rootState }) =>
export const updateFilesAfterCommit = (
{ commit, dispatch, state, rootState, rootGetters },
- { data, branch },
+ { data },
) => {
const selectedProject = rootState.projects[rootState.currentProjectId];
const lastCommit = {
@@ -126,15 +127,9 @@ export const updateFilesAfterCommit = (
changed: !!changedFile,
});
});
-
- if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH && rootGetters.activeFile) {
- router.push(
- `/project/${rootState.currentProjectId}/blob/${branch}/${rootGetters.activeFile.path}`,
- );
- }
};
-export const commitChanges = ({ commit, state, getters, dispatch, rootState }) => {
+export const commitChanges = ({ commit, state, getters, dispatch, rootState, rootGetters }) => {
const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH;
const payload = createCommitPayload(getters.branchName, newBranch, state, rootState);
const getCommitStatus = newBranch ? Promise.resolve(false) : dispatch('checkCommitStatus');
@@ -187,6 +182,34 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState }) =
commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true });
}, 5000);
})
+ .then(() => {
+ if (rootGetters.lastOpenedFile) {
+ dispatch(
+ 'openPendingTab',
+ {
+ file: rootGetters.lastOpenedFile,
+ },
+ { root: true },
+ )
+ .then(changeViewer => {
+ if (changeViewer) {
+ dispatch('updateViewer', 'diff', { root: true });
+ }
+ })
+ .catch(e => {
+ throw e;
+ });
+ } else {
+ dispatch('updateActivityBarView', activityBarViews.edit, { root: true });
+ dispatch('updateViewer', 'editor', { root: true });
+
+ router.push(
+ `/project/${rootState.currentProjectId}/blob/${getters.branchName}/${
+ rootGetters.activeFile.path
+ }`,
+ );
+ }
+ })
.then(() => dispatch('updateCommitAction', consts.COMMIT_TO_CURRENT_BRANCH));
})
.catch(err => {
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 87b39379338..08e22e5ad7e 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -5,6 +5,7 @@ export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG';
export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED';
export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED';
export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS';
+export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS';
// Project Mutation Types
export const SET_PROJECT = 'SET_PROJECT';
@@ -59,6 +60,7 @@ export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT';
export const ADD_PENDING_TAB = 'ADD_PENDING_TAB';
export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB';
+export const UPDATE_ACTIVITY_BAR_VIEW = 'UPDATE_ACTIVITY_BAR_VIEW';
export const UPDATE_TEMP_FLAG = 'UPDATE_TEMP_FLAG';
export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER';
export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index 539a07116b3..a257e2ef025 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -107,6 +107,21 @@ export default {
delayViewerUpdated,
});
},
+ [types.UPDATE_ACTIVITY_BAR_VIEW](state, currentActivityView) {
+ Object.assign(state, {
+ currentActivityView,
+ });
+ },
+ [types.SET_EMPTY_STATE_SVGS](
+ state,
+ { emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath },
+ ) {
+ Object.assign(state, {
+ emptyStateSvgPath,
+ noChangesStateSvgPath,
+ committedStateSvgPath,
+ });
+ },
[types.TOGGLE_FILE_FINDER](state, fileFindVisible) {
Object.assign(state, {
fileFindVisible,
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index c3041c77199..13f123b6630 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -1,3 +1,4 @@
+/* eslint-disable no-param-reassign */
import * as types from '../mutation_types';
export default {
@@ -169,32 +170,24 @@ export default {
});
},
[types.ADD_PENDING_TAB](state, { file, keyPrefix = 'pending' }) {
- const key = `${keyPrefix}-${file.key}`;
- const pendingTab = state.openFiles.find(f => f.key === key && f.pending);
- let openFiles = state.openFiles.map(f => Object.assign(f, { active: false, opened: false }));
-
- if (!pendingTab) {
- const openFile = openFiles.find(f => f.path === file.path);
-
- openFiles = openFiles.concat(openFile ? null : file).reduce((acc, f) => {
- if (!f) return acc;
-
- if (f.path === file.path) {
- return acc.concat({
- ...f,
- content: file.content,
- active: true,
- pending: true,
- opened: true,
- key,
- });
- }
-
- return acc.concat(f);
- }, []);
- }
-
- Object.assign(state, { openFiles });
+ state.entries[file.path].opened = false;
+ state.entries[file.path].active = false;
+ state.entries[file.path].lastOpenedAt = new Date().getTime();
+ state.openFiles.forEach(f =>
+ Object.assign(f, {
+ opened: false,
+ active: false,
+ }),
+ );
+ state.openFiles = [
+ {
+ ...file,
+ key: `${keyPrefix}-${file.key}`,
+ pending: true,
+ opened: true,
+ active: true,
+ },
+ ];
},
[types.REMOVE_PENDING_TAB](state, file) {
Object.assign(state, {
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index 0976d278559..e7411f16a4f 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -1,3 +1,5 @@
+import { activityBarViews, viewerTypes } from '../constants';
+
export default () => ({
currentProjectId: '',
currentBranchId: '',
@@ -16,8 +18,9 @@ export default () => ({
rightPanelCollapsed: false,
panelResizing: false,
entries: {},
- viewer: 'editor',
+ viewer: viewerTypes.edit,
delayViewerUpdated: false,
+ currentActivityView: activityBarViews.edit,
unusedSeal: true,
fileFindVisible: false,
});
diff --git a/app/assets/javascripts/mini_pipeline_graph_dropdown.js b/app/assets/javascripts/mini_pipeline_graph_dropdown.js
index 01399de4c62..f8257b6abab 100644
--- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js
+++ b/app/assets/javascripts/mini_pipeline_graph_dropdown.js
@@ -1,5 +1,3 @@
-/* eslint-disable no-new */
-
import $ from 'jquery';
import flash from './flash';
import axios from './lib/utils/axios_utils';
@@ -62,7 +60,7 @@ export default class MiniPipelineGraph {
*/
renderBuildsList(stageContainer, data) {
const dropdownContainer = stageContainer.parentElement.querySelector(
- `${this.dropdownListSelector} .js-builds-dropdown-list`,
+ `${this.dropdownListSelector} .js-builds-dropdown-list ul`,
);
dropdownContainer.innerHTML = data;
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 396a675b4ac..48642c4a086 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -99,10 +99,6 @@ export default {
'js-note-target-reopen': !this.isOpen,
};
},
- supportQuickActions() {
- // Disable quick actions support for Epics
- return this.noteableType !== constants.EPIC_NOTEABLE_TYPE;
- },
markdownDocsPath() {
return this.getNotesData.markdownDocsPath;
},
@@ -359,7 +355,7 @@ Please check your network connection and try again.`;
name="note[note]"
class="note-textarea js-vue-comment-form
js-gfm-input js-autosize markdown-area js-vue-textarea"
- :data-supports-quick-actions="supportQuickActions"
+ data-supports-quick-actions="true"
aria-label="Description"
v-model="note"
ref="textarea"
diff --git a/app/assets/javascripts/pages/projects/compare/show/index.js b/app/assets/javascripts/pages/projects/compare/show/index.js
index 2b4fd3c47c0..a626ed2d30b 100644
--- a/app/assets/javascripts/pages/projects/compare/show/index.js
+++ b/app/assets/javascripts/pages/projects/compare/show/index.js
@@ -1,8 +1,10 @@
import Diff from '~/diff';
import initChangesDropdown from '~/init_changes_dropdown';
+import GpgBadges from '~/gpg_badges';
document.addEventListener('DOMContentLoaded', () => {
new Diff(); // eslint-disable-line no-new
const paddingTop = 16;
initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight - paddingTop);
+ GpgBadges.fetch();
});
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
index 29ee73a2a6f..fd3491c7fe0 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -61,7 +61,7 @@ export default {
methods: {
onClickAction() {
$(this.$el).tooltip('hide');
- eventHub.$emit('graphAction', this.link);
+ eventHub.$emit('postAction', this.link);
this.linkRequested = this.link;
this.isDisabled = true;
},
diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
index 43121dd38f3..4027d26098f 100644
--- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue
@@ -87,7 +87,8 @@ export default {
data-toggle="dropdown"
data-container="body"
class="dropdown-menu-toggle build-content"
- :title="tooltipText">
+ :title="tooltipText"
+ >
<job-name-component
:name="job.name"
@@ -104,7 +105,8 @@ export default {
<ul>
<li
v-for="(item, i) in job.jobs"
- :key="i">
+ :key="i"
+ >
<job-component
:job="item"
css-class-job-name="mini-pipeline-graph-dropdown-item"
diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue
index 4fcd4b79f4a..c1f0f051b63 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue
@@ -108,7 +108,7 @@ export default {
<div
v-else
v-tooltip
- class="js-job-component-tooltip"
+ class="js-job-component-tooltip non-details-job-component"
:title="tooltipText"
:class="cssClassJobName"
data-html="true"
diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue
index 32cf3dba3c3..a65485c05eb 100644
--- a/app/assets/javascripts/pipelines/components/stage.vue
+++ b/app/assets/javascripts/pipelines/components/stage.vue
@@ -1,135 +1,140 @@
<script>
-
- /**
- * Renders each stage of the pipeline mini graph.
- *
- * Given the provided endpoint will make a request to
- * fetch the dropdown data when the stage is clicked.
- *
- * Request is made inside this component to make it reusable between:
- * 1. Pipelines main table
- * 2. Pipelines table in commit and Merge request views
- * 3. Merge request widget
- * 4. Commit widget
- */
-
- import $ from 'jquery';
- import Flash from '../../flash';
- import axios from '../../lib/utils/axios_utils';
- import eventHub from '../event_hub';
- import Icon from '../../vue_shared/components/icon.vue';
- import LoadingIcon from '../../vue_shared/components/loading_icon.vue';
- import tooltip from '../../vue_shared/directives/tooltip';
-
- export default {
- components: {
- LoadingIcon,
- Icon,
+/**
+ * Renders each stage of the pipeline mini graph.
+ *
+ * Given the provided endpoint will make a request to
+ * fetch the dropdown data when the stage is clicked.
+ *
+ * Request is made inside this component to make it reusable between:
+ * 1. Pipelines main table
+ * 2. Pipelines table in commit and Merge request views
+ * 3. Merge request widget
+ * 4. Commit widget
+ */
+
+import $ from 'jquery';
+import { __ } from '../../locale';
+import Flash from '../../flash';
+import axios from '../../lib/utils/axios_utils';
+import eventHub from '../event_hub';
+import Icon from '../../vue_shared/components/icon.vue';
+import LoadingIcon from '../../vue_shared/components/loading_icon.vue';
+import JobComponent from './graph/job_component.vue';
+import tooltip from '../../vue_shared/directives/tooltip';
+
+export default {
+ components: {
+ LoadingIcon,
+ Icon,
+ JobComponent,
+ },
+
+ directives: {
+ tooltip,
+ },
+
+ props: {
+ stage: {
+ type: Object,
+ required: true,
},
- directives: {
- tooltip,
+ updateDropdown: {
+ type: Boolean,
+ required: false,
+ default: false,
},
-
- props: {
- stage: {
- type: Object,
- required: true,
- },
-
- updateDropdown: {
- type: Boolean,
- required: false,
- default: false,
- },
+ },
+
+ data() {
+ return {
+ isLoading: false,
+ dropdownContent: '',
+ };
+ },
+
+ computed: {
+ dropdownClass() {
+ return this.dropdownContent.length > 0
+ ? 'js-builds-dropdown-container'
+ : 'js-builds-dropdown-loading';
},
- data() {
- return {
- isLoading: false,
- dropdownContent: '',
- };
+ triggerButtonClass() {
+ return `ci-status-icon-${this.stage.status.group}`;
},
- computed: {
- dropdownClass() {
- return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading';
- },
+ borderlessIcon() {
+ return `${this.stage.status.icon}_borderless`;
+ },
+ },
- triggerButtonClass() {
- return `ci-status-icon-${this.stage.status.group}`;
- },
+ watch: {
+ updateDropdown() {
+ if (this.updateDropdown && this.isDropdownOpen() && !this.isLoading) {
+ this.fetchJobs();
+ }
+ },
+ },
+
+ updated() {
+ if (this.dropdownContent.length > 0) {
+ this.stopDropdownClickPropagation();
+ }
+ },
+
+ methods: {
+ onClickStage() {
+ if (!this.isDropdownOpen()) {
+ eventHub.$emit('clickedDropdown');
+ this.isLoading = true;
+ this.fetchJobs();
+ }
+ },
- borderlessIcon() {
- return `${this.stage.status.icon}_borderless`;
- },
+ fetchJobs() {
+ axios
+ .get(this.stage.dropdown_path)
+ .then(({ data }) => {
+ this.dropdownContent = data.latest_statuses;
+ this.isLoading = false;
+ })
+ .catch(() => {
+ this.closeDropdown();
+ this.isLoading = false;
+
+ Flash(__('Something went wrong on our end.'));
+ });
},
- watch: {
- updateDropdown() {
- if (this.updateDropdown &&
- this.isDropdownOpen() &&
- !this.isLoading) {
- this.fetchJobs();
- }
- },
+ /**
+ * When the user right clicks or cmd/ctrl + click in the job name
+ * the dropdown should not be closed and the link should open in another tab,
+ * so we stop propagation of the click event inside the dropdown.
+ *
+ * Since this component is rendered multiple times per page we need to guarantee we only
+ * target the click event of this component.
+ */
+ stopDropdownClickPropagation() {
+ $(
+ '.js-builds-dropdown-list button, .js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item',
+ this.$el,
+ ).on('click', e => {
+ e.stopPropagation();
+ });
},
- updated() {
- if (this.dropdownContent.length > 0) {
- this.stopDropdownClickPropagation();
+ closeDropdown() {
+ if (this.isDropdownOpen()) {
+ $(this.$refs.dropdown).dropdown('toggle');
}
},
- methods: {
- onClickStage() {
- if (!this.isDropdownOpen()) {
- eventHub.$emit('clickedDropdown');
- this.isLoading = true;
- this.fetchJobs();
- }
- },
-
- fetchJobs() {
- axios.get(this.stage.dropdown_path)
- .then(({ data }) => {
- this.dropdownContent = data.html;
- this.isLoading = false;
- })
- .catch(() => {
- this.closeDropdown();
- this.isLoading = false;
-
- Flash('Something went wrong on our end.');
- });
- },
-
- /**
- * When the user right clicks or cmd/ctrl + click in the job name
- * the dropdown should not be closed and the link should open in another tab,
- * so we stop propagation of the click event inside the dropdown.
- *
- * Since this component is rendered multiple times per page we need to guarantee we only
- * target the click event of this component.
- */
- stopDropdownClickPropagation() {
- $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item'))
- .on('click', (e) => {
- e.stopPropagation();
- });
- },
-
- closeDropdown() {
- if (this.isDropdownOpen()) {
- $(this.$refs.dropdown).dropdown('toggle');
- }
- },
-
- isDropdownOpen() {
- return this.$el.classList.contains('open');
- },
+ isDropdownOpen() {
+ return this.$el.classList.contains('open');
},
- };
+ },
+};
</script>
<template>
@@ -168,7 +173,6 @@
>
<li
- :class="dropdownClass"
class="js-builds-dropdown-list scrollable-menu"
>
@@ -176,8 +180,16 @@
<ul
v-else
- v-html="dropdownContent"
>
+ <li
+ v-for="job in dropdownContent"
+ :key="job.id"
+ >
+ <job-component
+ :job="job"
+ css-class-job-name="mini-pipeline-graph-dropdown-item"
+ />
+ </li>
</ul>
</li>
</ul>
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 6584f96130b..04fe7958fe6 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -29,10 +29,10 @@ export default () => {
};
},
created() {
- eventHub.$on('graphAction', this.postAction);
+ eventHub.$on('postAction', this.postAction);
},
beforeDestroy() {
- eventHub.$off('graphAction', this.postAction);
+ eventHub.$off('postAction', this.postAction);
},
methods: {
postAction(action) {
diff --git a/app/assets/javascripts/projects_dropdown/components/app.vue b/app/assets/javascripts/projects_dropdown/components/app.vue
index 34a60dd574b..0bbd8a41753 100644
--- a/app/assets/javascripts/projects_dropdown/components/app.vue
+++ b/app/assets/javascripts/projects_dropdown/components/app.vue
@@ -100,9 +100,10 @@ export default {
fetchSearchedProjects(searchQuery) {
this.searchQuery = searchQuery;
this.toggleLoader(true);
- this.service.getSearchedProjects(this.searchQuery)
+ this.service
+ .getSearchedProjects(this.searchQuery)
.then(res => res.json())
- .then((results) => {
+ .then(results => {
this.toggleSearchProjectsList(true);
this.store.setSearchedProjects(results);
})
diff --git a/app/assets/javascripts/projects_dropdown/service/projects_service.js b/app/assets/javascripts/projects_dropdown/service/projects_service.js
index 7231f520933..ed1c3deead2 100644
--- a/app/assets/javascripts/projects_dropdown/service/projects_service.js
+++ b/app/assets/javascripts/projects_dropdown/service/projects_service.js
@@ -50,7 +50,7 @@ export default class ProjectsService {
} else {
// Check if project is already present in frequents list
// When found, update metadata of it.
- storedFrequentProjects = JSON.parse(storedRawProjects).map((projectItem) => {
+ storedFrequentProjects = JSON.parse(storedRawProjects).map(projectItem => {
if (projectItem.id === project.id) {
matchFound = true;
const diff = Math.abs(project.lastAccessedOn - projectItem.lastAccessedOn) / HOUR_IN_MS;
@@ -104,13 +104,17 @@ export default class ProjectsService {
return [];
}
- if (bp.getBreakpointSize() === 'sm' ||
- bp.getBreakpointSize() === 'xs') {
+ if (bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'xs') {
frequentProjectsCount = FREQUENT_PROJECTS.LIST_COUNT_MOBILE;
}
- const frequentProjects = storedFrequentProjects
- .filter(project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY);
+ const frequentProjects = storedFrequentProjects.filter(
+ project => project.frequency >= FREQUENT_PROJECTS.ELIGIBLE_FREQUENCY,
+ );
+
+ if (!frequentProjects || frequentProjects.length === 0) {
+ return [];
+ }
// Sort all frequent projects in decending order of frequency
// and then by lastAccessedOn with recent most first
diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss
index 05cb0196ced..0bbd6eb27c1 100644
--- a/app/assets/stylesheets/framework/gitlab_theme.scss
+++ b/app/assets/stylesheets/framework/gitlab_theme.scss
@@ -177,25 +177,6 @@
}
}
- // Web IDE
- .ide-sidebar-link {
- color: $color-200;
- background-color: $color-700;
-
- &:hover,
- &:focus {
- background-color: $color-500;
- }
-
- &:active {
- background: $color-800;
- }
- }
-
- .branch-container {
- border-left-color: $color-700;
- }
-
.branch-header-title {
color: $color-700;
}
@@ -203,6 +184,13 @@
.ide-file-list .file.file-active {
color: $color-700;
}
+
+ .ide-sidebar-link {
+ &.active {
+ color: $color-700;
+ box-shadow: inset 3px 0 $color-700;
+ }
+ }
}
body {
@@ -343,9 +331,5 @@ body {
.sidebar-top-level-items > li.active .badge {
color: $theme-gray-900;
}
-
- .ide-sidebar-link {
- color: $white-light;
- }
}
}
diff --git a/app/assets/stylesheets/framework/terms.scss b/app/assets/stylesheets/framework/terms.scss
index dadfaf1c3f9..16293d32dfa 100644
--- a/app/assets/stylesheets/framework/terms.scss
+++ b/app/assets/stylesheets/framework/terms.scss
@@ -1,4 +1,8 @@
.terms {
+ .with-performance-bar & {
+ margin-top: 0;
+ }
+
.alert-wrapper {
min-height: $header-height + $gl-padding;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 3d28df455bb..8d43bb32175 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -690,6 +690,8 @@ $stage-hover-bg: $gray-darker;
$ci-action-icon-size: 22px;
$pipeline-dropdown-line-height: 20px;
$pipeline-dropdown-status-icon-size: 18px;
+$ci-action-dropdown-button-size: 24px;
+$ci-action-dropdown-svg-size: 12px;
/*
CI variable lists
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 66db4917178..3581dd36a10 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -156,10 +156,6 @@
.dropdown-menu {
z-index: 300;
}
-
- .ci-action-icon-wrapper {
- line-height: 16px;
- }
}
.mini-pipeline-graph-dropdown-toggle {
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 3a8ec779c14..02803e7b040 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -22,7 +22,6 @@
}
.ci-table {
-
.label {
margin-bottom: 3px;
}
@@ -123,7 +122,6 @@
}
.branch-commit {
-
.ref-name {
font-weight: $gl-font-weight-bold;
max-width: 100px;
@@ -481,43 +479,6 @@
@extend .build-content:hover;
}
- .ci-action-icon-container {
- position: absolute;
- right: 5px;
- top: 5px;
-
- // Action Icons in big pipeline-graph nodes
- &.ci-action-icon-wrapper {
- height: 30px;
- width: 30px;
- background: $white-light;
- border: 1px solid $border-color;
- border-radius: 100%;
- display: block;
-
- &:hover {
- background-color: $stage-hover-bg;
- border: 1px solid $dropdown-toggle-active-border-color;
-
- svg {
- fill: $gl-text-color;
- }
- }
-
- svg {
- fill: $gl-text-color-secondary;
- position: relative;
- top: -1px;
- }
-
- &.play {
- svg {
- left: 2px;
- }
- }
- }
- }
-
.ci-status-icon svg {
height: 20px;
width: 20px;
@@ -548,7 +509,6 @@
border: 1px solid $dropdown-toggle-active-border-color;
}
-
// Connect first build in each stage with right horizontal line
&:first-child {
&::after {
@@ -602,6 +562,43 @@
}
}
}
+
+ .ci-action-icon-container {
+ position: absolute;
+ right: 5px;
+ top: 5px;
+
+ // Action Icons in big pipeline-graph nodes
+ &.ci-action-icon-wrapper {
+ height: 30px;
+ width: 30px;
+ background: $white-light;
+ border: 1px solid $border-color;
+ border-radius: 100%;
+ display: block;
+
+ &:hover {
+ background-color: $stage-hover-bg;
+ border: 1px solid $dropdown-toggle-active-border-color;
+
+ svg {
+ fill: $gl-text-color;
+ }
+ }
+
+ svg {
+ fill: $gl-text-color-secondary;
+ position: relative;
+ top: -1px;
+ }
+
+ &.play {
+ svg {
+ left: 2px;
+ }
+ }
+ }
+ }
}
// Triggers the dropdown in the big pipeline graph
@@ -710,93 +707,77 @@ button.mini-pipeline-graph-dropdown-toggle {
}
}
-// dropdown content for big and mini pipeline
+/**
+ Action icons inside dropdowns:
+ - mini graph in pipelines table
+ - dropdown in big graph
+ - mini graph in MR widget pipeline
+ - mini graph in Commit widget pipeline
+*/
.big-pipeline-graph-dropdown-menu,
.mini-pipeline-graph-dropdown-menu {
width: 240px;
max-width: 240px;
- .scrollable-menu {
+ // override dropdown.scss
+ &.dropdown-menu li button,
+ &.dropdown-menu li a.ci-action-icon-container {
padding: 0;
- max-height: 245px;
- overflow: auto;
+ text-align: center;
}
- li {
- position: relative;
+ .ci-action-icon-container {
+ position: absolute;
+ right: 8px;
+ top: 8px;
- // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered
- &:hover > .mini-pipeline-graph-dropdown-item,
- &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item {
- @extend .mini-pipeline-graph-dropdown-item:hover;
- }
+ &.ci-action-icon-wrapper {
+ height: $ci-action-dropdown-button-size;
+ width: $ci-action-dropdown-button-size;
- // Action icon on the right
- a.ci-action-icon-wrapper {
- border-radius: 50%;
+ background: $white-light;
border: 1px solid $border-color;
- width: $ci-action-icon-size;
- height: $ci-action-icon-size;
- padding: 2px 0 0 5px;
- font-size: 12px;
- background-color: $white-light;
- position: absolute;
- top: 50%;
- right: $gl-padding;
- margin-top: -#{$ci-action-icon-size / 2};
+ border-radius: 50%;
+ display: block;
- &:hover,
- &:focus {
+ &:hover {
background-color: $stage-hover-bg;
border: 1px solid $dropdown-toggle-active-border-color;
+
+ svg {
+ fill: $gl-text-color;
+ }
}
svg {
+ width: $ci-action-dropdown-svg-size;
+ height: $ci-action-dropdown-svg-size;
fill: $gl-text-color-secondary;
- width: #{$ci-action-icon-size - 6};
- height: #{$ci-action-icon-size - 6};
- left: -3px;
position: relative;
- top: -1px;
-
- &.icon-action-stop,
- &.icon-action-cancel {
- width: 12px;
- height: 12px;
- top: 1px;
- left: -1px;
- }
-
- &.icon-action-play {
- width: 11px;
- height: 11px;
- top: 1px;
- left: 1px;
- }
-
- &.icon-action-retry {
- width: 16px;
- height: 16px;
- top: 0;
- left: -3px;
- }
+ top: 0;
+ vertical-align: initial;
}
+ }
+ }
- &:hover svg,
- &:focus svg {
- fill: $gl-text-color;
- }
+ // SVGs in the commit widget and mr widget
+ a.ci-action-icon-container.ci-action-icon-wrapper svg {
+ top: 2px;
+ }
- &.icon-action-retry,
- &.icon-action-play {
- svg {
- width: #{$ci-action-icon-size - 6};
- height: #{$ci-action-icon-size - 6};
- left: 8px;
- }
- }
+ .scrollable-menu {
+ padding: 0;
+ max-height: 245px;
+ overflow: auto;
+ }
+ li {
+ position: relative;
+ // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered
+ &:hover > .mini-pipeline-graph-dropdown-item,
+ &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item {
+ @extend .mini-pipeline-graph-dropdown-item:hover;
}
// link to the build
@@ -808,6 +789,11 @@ button.mini-pipeline-graph-dropdown-toggle {
line-height: $line-height-base;
white-space: nowrap;
+ // Match dropdown.scss for all `a` tags
+ &.non-details-job-component {
+ padding: 8px 16px;
+ }
+
.ci-job-name-component {
align-items: center;
display: flex;
@@ -939,7 +925,7 @@ button.mini-pipeline-graph-dropdown-toggle {
&.dropdown-menu {
transform: translate(-80%, 0);
- @media(min-width: $screen-md-min) {
+ @media (min-width: $screen-md-min) {
transform: translate(-50%, 0);
right: auto;
left: 50%;
diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss
index 888757c12d8..7f1f0c1f5f1 100644
--- a/app/assets/stylesheets/pages/repo.scss
+++ b/app/assets/stylesheets/pages/repo.scss
@@ -121,14 +121,6 @@
.multi-file-loading-container {
margin-top: 10px;
padding: 10px;
-
- .animation-container {
- background: $gray-light;
-
- div {
- background: $gray-light;
- }
- }
}
.multi-file-table-col-commit-message {
@@ -155,69 +147,56 @@
}
li {
- position: relative;
- }
-
- .dropdown {
display: flex;
- margin-left: auto;
- margin-bottom: 1px;
- padding: 0 $grid-size;
- border-left: 1px solid $white-dark;
- background-color: $white-light;
-
- &.shadow {
- box-shadow: 0 0 10px $dropdown-shadow-color;
- }
+ align-items: center;
+ padding: $grid-size $gl-padding;
+ background-color: $gray-normal;
+ border-right: 1px solid $white-dark;
+ border-bottom: 1px solid $white-dark;
- .btn {
- margin-top: auto;
- margin-bottom: auto;
+ &.active {
+ background-color: $white-light;
+ border-bottom-color: $white-light;
}
}
}
.multi-file-tab {
- @include str-truncated(150px);
- padding: ($gl-padding / 2) ($gl-padding + 12) ($gl-padding / 2) $gl-padding;
- background-color: $gray-normal;
- border-right: 1px solid $white-dark;
- border-bottom: 1px solid $white-dark;
+ @include str-truncated(141px);
cursor: pointer;
svg {
vertical-align: middle;
}
-
- &.active {
- background-color: $white-light;
- border-bottom-color: $white-light;
- }
}
.multi-file-tab-close {
- position: absolute;
- right: 8px;
- top: 50%;
width: 16px;
height: 16px;
padding: 0;
+ margin-left: $grid-size;
background: none;
border: 0;
border-radius: $border-radius-default;
color: $theme-gray-900;
- transform: translateY(-50%);
svg {
position: relative;
top: -1px;
}
- &:hover {
+ .ide-file-changed-icon {
+ display: block;
+ position: relative;
+ top: 1px;
+ right: -2px;
+ }
+
+ &:not([disabled]):hover {
background-color: $theme-gray-200;
}
- &:focus {
+ &:not([disabled]):focus {
background-color: $blue-500;
color: $white-light;
outline: 0;
@@ -248,6 +227,17 @@
display: none;
}
+ .is-readonly,
+ .editor.original {
+ .view-lines {
+ cursor: default;
+ }
+
+ .cursors-layer {
+ display: none;
+ }
+ }
+
.monaco-diff-editor.vs {
.editor.modified {
box-shadow: none;
@@ -306,15 +296,12 @@
.margin-view-overlays .delete-sign {
opacity: 0.4;
}
-
- .cursors-layer {
- display: none;
- }
}
}
.multi-file-editor-holder {
height: 100%;
+ min-height: 0;
}
.preview-container {
@@ -380,6 +367,7 @@
.ide-btn-group {
padding: $gl-padding-4 $gl-vert-padding;
+ line-height: 24px;
}
.ide-status-bar {
@@ -433,28 +421,35 @@
.multi-file-commit-panel {
display: flex;
position: relative;
- flex-direction: column;
width: 340px;
padding: 0;
background-color: $gray-light;
- padding-right: 3px;
+ padding-right: 1px;
+
+ .context-header {
+ width: auto;
+ margin-right: 0;
+
+ a:hover,
+ a:focus {
+ text-decoration: none;
+ }
+ }
.projects-sidebar {
min-height: 0;
display: flex;
flex-direction: column;
flex: 1;
-
- .context-header {
- width: auto;
- margin-right: 0;
- }
}
.multi-file-commit-panel-inner {
+ position: relative;
display: flex;
flex-direction: column;
height: 100%;
+ min-width: 0;
+ width: 100%;
}
.multi-file-commit-panel-inner-scroll {
@@ -462,68 +457,10 @@
flex: 1;
flex-direction: column;
overflow: auto;
- }
-
- &.is-collapsed {
- width: 60px;
-
- .multi-file-commit-list {
- padding-top: $gl-padding;
- overflow: hidden;
- }
-
- .multi-file-context-bar-icon {
- align-items: center;
-
- svg {
- float: none;
- margin: 0;
- }
- }
- }
-
- .branch-container {
- border-left: 4px solid;
- margin-bottom: $gl-bar-padding;
- }
-
- .branch-header {
- background: $white-dark;
- display: flex;
- }
-
- .branch-header-title {
- flex: 1;
- padding: $grid-size $gl-padding;
- font-weight: $gl-font-weight-bold;
-
- svg {
- vertical-align: middle;
- }
- }
-
- .branch-header-btns {
- padding: $gl-vert-padding $gl-padding;
- }
-
- .left-collapse-btn {
- display: none;
- background: $gray-light;
- text-align: left;
+ background-color: $white-light;
+ border-left: 1px solid $white-dark;
border-top: 1px solid $white-dark;
-
- svg {
- vertical-align: middle;
- }
- }
-}
-
-.multi-file-context-bar-icon {
- padding: 10px;
-
- svg {
- margin-right: 10px;
- float: left;
+ border-top-left-radius: $border-radius-small;
}
}
@@ -549,14 +486,13 @@
align-items: center;
margin-bottom: 0;
border-bottom: 1px solid $white-dark;
- padding: $gl-btn-padding 0;
- min-height: 56px;
+ padding: $gl-btn-padding $gl-padding;
}
.multi-file-commit-panel-header-title {
display: flex;
flex: 1;
- padding-left: $grid-size;
+ align-items: center;
svg {
margin-right: $gl-btn-padding;
@@ -572,7 +508,7 @@
.multi-file-commit-list {
flex: 1;
overflow: auto;
- padding: $gl-padding 0;
+ padding: $gl-padding;
min-height: 60px;
}
@@ -667,30 +603,24 @@
}
.multi-file-commit-form {
+ position: relative;
padding: $gl-padding;
+ background-color: $white-light;
border-top: 1px solid $white-dark;
+ border-left: 1px solid $white-dark;
+ transition: all 0.3s ease;
.btn {
font-size: $gl-font-size;
}
+
+ .multi-file-commit-panel-success-message {
+ top: 0;
+ }
}
.multi-file-commit-panel-bottom {
position: relative;
-
- .multi-file-commit-panel-success-message {
- position: absolute;
- top: 1px;
- left: 3px;
- bottom: 0;
- right: 0;
- z-index: 10;
- background: $gray-light;
- overflow: auto;
- display: flex;
- flex-direction: column;
- justify-content: center;
- }
}
.dirty-diff {
@@ -826,7 +756,7 @@
position: absolute;
top: 0;
bottom: 0;
- width: 3px;
+ width: 1px;
background-color: $white-dark;
&.dragright {
@@ -840,42 +770,40 @@
.ide-commit-list-container {
display: flex;
+ flex: 1;
flex-direction: column;
width: 100%;
- padding: 0 16px;
+ min-height: 140px;
- &:not(.is-collapsed) {
- flex: 1;
- min-height: 140px;
- }
-
- &.is-collapsed {
- .multi-file-commit-panel-header {
- margin-left: -$gl-padding;
- margin-right: -$gl-padding;
-
- svg {
- margin-left: auto;
- margin-right: auto;
- }
-
- .multi-file-commit-panel-collapse-btn {
- margin-right: auto;
- margin-left: auto;
- border-left: 0;
- }
- }
+ &.is-first {
+ border-bottom: 1px solid $white-dark;
}
}
.ide-staged-action-btn {
margin-left: auto;
- color: $gl-link-color;
+ line-height: 22px;
+}
+
+.ide-commit-file-count {
+ min-width: 22px;
+ margin-left: auto;
+ background-color: $gray-light;
+ border-radius: $border-radius-default;
+ border: 1px solid $white-dark;
+ line-height: 20px;
+ text-align: center;
}
.ide-commit-radios {
label {
font-weight: normal;
+
+ &.is-disabled {
+ .ide-radio-label {
+ text-decoration: line-through;
+ }
+ }
}
.help-block {
@@ -888,17 +816,58 @@
margin-left: 25px;
}
-.ide-external-links {
- p {
- margin: 0;
- }
-}
-
.ide-sidebar-link {
- padding: $gl-padding-8 $gl-padding;
display: flex;
align-items: center;
- font-weight: $gl-font-weight-bold;
+ position: relative;
+ height: 60px;
+ width: 100%;
+ padding: 0 $gl-padding;
+ color: $gl-text-color-secondary;
+ background-color: transparent;
+ border: 0;
+ border-top: 1px solid transparent;
+ border-bottom: 1px solid transparent;
+ outline: 0;
+
+ svg {
+ margin: 0 auto;
+ }
+
+ &:hover {
+ color: $gl-text-color;
+ background-color: $theme-gray-100;
+ }
+
+ &:focus {
+ color: $gl-text-color;
+ background-color: $theme-gray-200;
+ }
+
+ &.active {
+ // extend width over border of sidebar section
+ width: calc(100% + 1px);
+ padding-right: $gl-padding + 1px;
+ background-color: $white-light;
+ border-top-color: $white-dark;
+ border-bottom-color: $white-dark;
+
+ &::after {
+ content: '';
+ position: absolute;
+ right: -1px;
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background: $white-light;
+ }
+ }
+}
+
+.ide-activity-bar {
+ position: relative;
+ flex: 0 0 60px;
+ z-index: 1;
}
.ide-file-finder-overlay {
@@ -992,6 +961,80 @@
resize: none;
}
+.ide-tree-header {
+ display: flex;
+ align-items: center;
+ padding: 10px 0;
+ margin-left: 10px;
+ margin-right: 10px;
+ border-bottom: 1px solid $white-dark;
+
+ .ide-new-btn {
+ margin-left: auto;
+ }
+}
+
+.ide-sidebar-branch-title {
+ font-weight: $gl-font-weight-normal;
+
+ svg {
+ position: relative;
+ top: 3px;
+ margin-top: -1px;
+ }
+}
+
+.commit-form-compact {
+ .btn {
+ margin-bottom: 8px;
+ }
+
+ p {
+ margin-bottom: 0;
+ }
+}
+
+.commit-form-slide-up-enter-active,
+.commit-form-slide-up-leave-active {
+ position: absolute;
+ top: 16px;
+ left: 16px;
+ right: 16px;
+ transition: all 0.3s ease;
+}
+
+.is-full .commit-form-slide-up-enter,
+.is-compact .commit-form-slide-up-leave-to {
+ transform: translateY(100%);
+}
+
+.is-full .commit-form-slide-up-enter-to,
+.is-compact .commit-form-slide-up-leave {
+ transform: translateY(0);
+}
+
+.commit-form-slide-up-enter,
+.commit-form-slide-up-leave-to {
+ opacity: 0;
+}
+
+.ide-review-header {
+ flex-direction: column;
+ align-items: flex-start;
+
+ .dropdown {
+ margin-left: auto;
+ }
+
+ a {
+ color: $gl-link-color;
+ }
+}
+
+.ide-review-sub-header {
+ color: $gl-text-color-secondary;
+}
+
.ide-tree-changes {
display: flex;
align-items: center;
@@ -1001,3 +1044,37 @@
.ide-new-modal-label {
line-height: 34px;
}
+
+.multi-file-commit-panel-success-message {
+ position: absolute;
+ top: 61px;
+ left: 1px;
+ bottom: 0;
+ right: 0;
+ z-index: 10;
+ background: $white-light;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.ide-review-button-holder {
+ display: flex;
+ width: 100%;
+ align-items: center;
+}
+
+.ide-context-header {
+ .avatar {
+ flex: 0 0 40px;
+ }
+}
+
+.ide-sidebar-project-title {
+ min-width: 0;
+
+ .sidebar-context-title {
+ white-space: nowrap;
+ }
+}
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 2b0c2ca97c0..f93e500a07a 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -8,8 +8,11 @@ class Projects::CompareController < Projects::ApplicationController
# Authorize
before_action :require_non_empty_project
before_action :authorize_download_code!
- before_action :define_ref_vars, only: [:index, :show, :diff_for_path]
- before_action :define_diff_vars, only: [:show, :diff_for_path]
+ # Defining ivars
+ before_action :define_diffs, only: [:show, :diff_for_path]
+ before_action :define_environment, only: [:show]
+ before_action :define_diff_notes_disabled, only: [:show, :diff_for_path]
+ before_action :define_commits, only: [:show, :diff_for_path, :signatures]
before_action :merge_request, only: [:index, :show]
def index
@@ -22,9 +25,9 @@ class Projects::CompareController < Projects::ApplicationController
end
def diff_for_path
- return render_404 unless @compare
+ return render_404 unless compare
- render_diff_for_path(@compare.diffs(diff_options))
+ render_diff_for_path(compare.diffs(diff_options))
end
def create
@@ -41,30 +44,60 @@ class Projects::CompareController < Projects::ApplicationController
end
end
+ def signatures
+ respond_to do |format|
+ format.json do
+ render json: {
+ signatures: @commits.select(&:has_signature?).map do |commit|
+ {
+ commit_sha: commit.sha,
+ html: view_to_html_string('projects/commit/_signature', signature: commit.signature)
+ }
+ end
+ }
+ end
+ end
+ end
+
private
- def define_ref_vars
- @start_ref = Addressable::URI.unescape(params[:from])
+ def compare
+ return @compare if defined?(@compare)
+
+ @compare = CompareService.new(@project, head_ref).execute(@project, start_ref)
+ end
+
+ def start_ref
+ @start_ref ||= Addressable::URI.unescape(params[:from])
+ end
+
+ def head_ref
+ return @ref if defined?(@ref)
+
@ref = @head_ref = Addressable::URI.unescape(params[:to])
end
- def define_diff_vars
- @compare = CompareService.new(@project, @head_ref)
- .execute(@project, @start_ref)
+ def define_commits
+ @commits = compare.present? ? prepare_commits_for_rendering(compare.commits) : []
+ end
- if @compare
- @commits = prepare_commits_for_rendering(@compare.commits)
- @diffs = @compare.diffs(diff_options)
+ def define_diffs
+ @diffs = compare.present? ? compare.diffs(diff_options) : []
+ end
- environment_params = @repository.branch_exists?(@head_ref) ? { ref: @head_ref } : { commit: @compare.commit }
+ def define_environment
+ if compare
+ environment_params = @repository.branch_exists?(head_ref) ? { ref: head_ref } : { commit: compare.commit }
@environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
-
- @diff_notes_disabled = true
end
end
+ def define_diff_notes_disabled
+ @diff_notes_disabled = compare.present?
+ end
+
def merge_request
@merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).execute.opened
- .find_by(source_project: @project, source_branch: @head_ref, target_branch: @start_ref)
+ .find_by(source_project: @project, source_branch: head_ref, target_branch: start_ref)
end
end
diff --git a/app/controllers/projects/mirrors_controller.rb b/app/controllers/projects/mirrors_controller.rb
new file mode 100644
index 00000000000..5698ff4e706
--- /dev/null
+++ b/app/controllers/projects/mirrors_controller.rb
@@ -0,0 +1,67 @@
+class Projects::MirrorsController < Projects::ApplicationController
+ include RepositorySettingsRedirect
+
+ # Authorize
+ before_action :remote_mirror, only: [:update]
+ before_action :check_mirror_available!
+ before_action :authorize_admin_project!
+
+ layout "project_settings"
+
+ def show
+ redirect_to_repository_settings(project)
+ end
+
+ def update
+ if project.update_attributes(mirror_params)
+ flash[:notice] = 'Mirroring settings were successfully updated.'
+ else
+ flash[:alert] = project.errors.full_messages.join(', ').html_safe
+ end
+
+ respond_to do |format|
+ format.html { redirect_to_repository_settings(project) }
+ format.json do
+ if project.errors.present?
+ render json: project.errors, status: :unprocessable_entity
+ else
+ render json: ProjectMirrorSerializer.new.represent(project)
+ end
+ end
+ end
+ end
+
+ def update_now
+ if params[:sync_remote]
+ project.update_remote_mirrors
+ flash[:notice] = "The remote repository is being updated..."
+ end
+
+ redirect_to_repository_settings(project)
+ end
+
+ private
+
+ def remote_mirror
+ @remote_mirror = project.remote_mirrors.first_or_initialize
+ end
+
+ def check_mirror_available!
+ Gitlab::CurrentSettings.current_application_settings.mirror_available || current_user&.admin?
+ end
+
+ def mirror_params_attributes
+ [
+ remote_mirrors_attributes: %i[
+ url
+ id
+ enabled
+ only_protected_branches
+ ]
+ ]
+ end
+
+ def mirror_params
+ params.require(:project).permit(mirror_params_attributes)
+ end
+end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 1ee273091d4..898e88344db 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -104,9 +104,18 @@ class Projects::PipelinesController < Projects::ApplicationController
@stage = pipeline.legacy_stage(params[:stage])
return not_found unless @stage
- respond_to do |format|
- format.json { render json: { html: view_to_html_string('projects/pipelines/_stage') } }
- end
+ render json: StageSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@stage, details: true)
+ end
+
+ # TODO: This endpoint is used by mini-pipeline-graph
+ # TODO: This endpoint should be migrated to `stage.json`
+ def stage_ajax
+ @stage = pipeline.legacy_stage(params[:stage])
+ return not_found unless @stage
+
+ render json: { html: view_to_html_string('projects/pipelines/_stage') }
end
def retry
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index f17056f13e0..4697af4f26a 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -2,6 +2,7 @@ module Projects
module Settings
class RepositoryController < Projects::ApplicationController
before_action :authorize_admin_project!
+ before_action :remote_mirror, only: [:show]
def show
render_show
@@ -25,6 +26,7 @@ module Projects
define_deploy_token
define_protected_refs
+ remote_mirror
render 'show'
end
@@ -41,6 +43,10 @@ module Projects
load_gon_index
end
+ def remote_mirror
+ @remote_mirror = project.remote_mirrors.first_or_initialize
+ end
+
def access_levels_options
{
create_access_levels: levels_for_dropdown,
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 6aa307b4db4..aa4569500b8 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -258,4 +258,17 @@ module ApplicationHelper
_('You are on a read-only GitLab instance.')
end
+
+ def autocomplete_data_sources(object, noteable_type)
+ return {} unless object && noteable_type
+
+ {
+ members: members_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
+ issues: issues_project_autocomplete_sources_path(object),
+ merge_requests: merge_requests_project_autocomplete_sources_path(object),
+ labels: labels_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
+ milestones: milestones_project_autocomplete_sources_path(object),
+ commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id])
+ }
+ end
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 1bf98d550b0..b948e431882 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -250,7 +250,8 @@ module ApplicationSettingsHelper
:version_check_enabled,
:allow_local_requests_from_hooks_and_services,
:enforce_terms,
- :terms
+ :terms,
+ :mirror_available
]
end
end
diff --git a/app/models/ability.rb b/app/models/ability.rb
index 618d4af4272..bb600eaccba 100644
--- a/app/models/ability.rb
+++ b/app/models/ability.rb
@@ -10,6 +10,14 @@ class Ability
end
end
+ # Given a list of users and a group this method returns the users that can
+ # read the given group.
+ def users_that_can_read_group(users, group)
+ DeclarativePolicy.subject_scope do
+ users.select { |u| allowed?(u, :read_group, group) }
+ end
+ end
+
# Given a list of users and a snippet this method returns the users that can
# read the given snippet.
def users_that_can_read_personal_snippet(users, snippet)
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index a734cc7a26b..451e512aef7 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -334,7 +334,8 @@ class ApplicationSetting < ActiveRecord::Base
gitaly_timeout_fast: 10,
gitaly_timeout_medium: 30,
gitaly_timeout_default: 55,
- allow_local_requests_from_hooks_and_services: false
+ allow_local_requests_from_hooks_and_services: false,
+ mirror_available: true
}
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 9000ad860e9..61c10c427dd 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -19,6 +19,7 @@ module Ci
has_one :last_deployment, -> { order('deployments.id DESC') }, as: :deployable, class_name: 'Deployment'
has_many :trace_sections, class_name: 'Ci::BuildTraceSection'
+ has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id
has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent
has_one :job_artifacts_archive, -> { where(file_type: Ci::JobArtifact.file_types[:archive]) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
new file mode 100644
index 00000000000..4856f10846c
--- /dev/null
+++ b/app/models/ci/build_trace_chunk.rb
@@ -0,0 +1,180 @@
+module Ci
+ class BuildTraceChunk < ActiveRecord::Base
+ include FastDestroyAll
+ extend Gitlab::Ci::Model
+
+ belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id
+
+ default_value_for :data_store, :redis
+
+ WriteError = Class.new(StandardError)
+
+ CHUNK_SIZE = 128.kilobytes
+ CHUNK_REDIS_TTL = 1.week
+ WRITE_LOCK_RETRY = 10
+ WRITE_LOCK_SLEEP = 0.01.seconds
+ WRITE_LOCK_TTL = 1.minute
+
+ enum data_store: {
+ redis: 1,
+ db: 2
+ }
+
+ class << self
+ def redis_data_key(build_id, chunk_index)
+ "gitlab:ci:trace:#{build_id}:chunks:#{chunk_index}"
+ end
+
+ def redis_data_keys
+ redis.pluck(:build_id, :chunk_index).map do |data|
+ redis_data_key(data.first, data.second)
+ end
+ end
+
+ def redis_delete_data(keys)
+ return if keys.empty?
+
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.del(keys)
+ end
+ end
+
+ ##
+ # FastDestroyAll concerns
+ def begin_fast_destroy
+ redis_data_keys
+ end
+
+ ##
+ # FastDestroyAll concerns
+ def finalize_fast_destroy(keys)
+ redis_delete_data(keys)
+ end
+ end
+
+ ##
+ # Data is memoized for optimizing #size and #end_offset
+ def data
+ @data ||= get_data.to_s
+ end
+
+ def truncate(offset = 0)
+ raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0
+ return if offset == size # Skip the following process as it doesn't affect anything
+
+ self.append("", offset)
+ end
+
+ def append(new_data, offset)
+ raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0
+ raise ArgumentError, 'Chunk size overflow' if CHUNK_SIZE < (offset + new_data.bytesize)
+
+ set_data(data.byteslice(0, offset) + new_data)
+ end
+
+ def size
+ data&.bytesize.to_i
+ end
+
+ def start_offset
+ chunk_index * CHUNK_SIZE
+ end
+
+ def end_offset
+ start_offset + size
+ end
+
+ def range
+ (start_offset...end_offset)
+ end
+
+ def use_database!
+ in_lock do
+ break if db?
+ break unless size > 0
+
+ self.update!(raw_data: data, data_store: :db)
+ self.class.redis_delete_data([redis_data_key])
+ end
+ end
+
+ private
+
+ def get_data
+ if redis?
+ redis_data
+ elsif db?
+ raw_data
+ else
+ raise 'Unsupported data store'
+ end&.force_encoding(Encoding::BINARY) # Redis/Database return UTF-8 string as default
+ end
+
+ def set_data(value)
+ raise ArgumentError, 'too much data' if value.bytesize > CHUNK_SIZE
+
+ in_lock do
+ if redis?
+ redis_set_data(value)
+ elsif db?
+ self.raw_data = value
+ else
+ raise 'Unsupported data store'
+ end
+
+ @data = value
+
+ save! if changed?
+ end
+
+ schedule_to_db if full?
+ end
+
+ def schedule_to_db
+ return if db?
+
+ Ci::BuildTraceChunkFlushWorker.perform_async(id)
+ end
+
+ def full?
+ size == CHUNK_SIZE
+ end
+
+ def redis_data
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.get(redis_data_key)
+ end
+ end
+
+ def redis_set_data(data)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.set(redis_data_key, data, ex: CHUNK_REDIS_TTL)
+ end
+ end
+
+ def redis_data_key
+ self.class.redis_data_key(build_id, chunk_index)
+ end
+
+ def in_lock
+ write_lock_key = "trace_write:#{build_id}:chunks:#{chunk_index}"
+
+ lease = Gitlab::ExclusiveLease.new(write_lock_key, timeout: WRITE_LOCK_TTL)
+ retry_count = 0
+
+ until uuid = lease.try_obtain
+ # Keep trying until we obtain the lease. To prevent hammering Redis too
+ # much we'll wait for a bit between retries.
+ sleep(WRITE_LOCK_SLEEP)
+ break if WRITE_LOCK_RETRY < (retry_count += 1)
+ end
+
+ raise WriteError, 'Failed to obtain write lock' unless uuid
+
+ self.reload if self.persisted?
+ return yield
+ ensure
+ Gitlab::ExclusiveLease.cancel(write_lock_key, uuid)
+ end
+ end
+end
diff --git a/app/models/concerns/fast_destroy_all.rb b/app/models/concerns/fast_destroy_all.rb
new file mode 100644
index 00000000000..7ea042c6742
--- /dev/null
+++ b/app/models/concerns/fast_destroy_all.rb
@@ -0,0 +1,91 @@
+##
+# This module is for replacing `dependent: :destroy` and `before_destroy` hooks.
+#
+# In general, `destroy_all` is inefficient because it calls each callback with `DELETE` queries i.e. O(n), whereas,
+# `delete_all` is efficient as it deletes all rows with a single `DELETE` query.
+#
+# It's better to use `delete_all` as our best practice, however,
+# if external data (e.g. ObjectStorage, FileStorage or Redis) are assosiated with database records,
+# it is difficult to accomplish it.
+#
+# This module defines a format to use `delete_all` and delete associated external data.
+# Here is an exmaple
+#
+# Situation
+# - `Project` has many `Ci::BuildTraceChunk` through `Ci::Build`
+# - `Ci::BuildTraceChunk` stores associated data in Redis, so it relies on `dependent: :destroy` and `before_destroy` for the deletion
+#
+# How to use
+# - Define `use_fast_destroy :build_trace_chunks` in `Project` model.
+# - Define `begin_fast_destroy` and `finalize_fast_destroy(params)` in `Ci::BuildTraceChunk` model.
+# - Use `fast_destroy_all` instead of `destroy` and `destroy_all`
+# - Remove `dependent: :destroy` and `before_destroy` as it's no longer need
+#
+# Expectation
+# - When a project is `destroy`ed, the associated trace_chunks will be deleted by `delete_all`,
+# and the associated data will be removed, too.
+# - When `fast_destroy_all` is called, it also performns as same.
+module FastDestroyAll
+ extend ActiveSupport::Concern
+
+ ForbiddenActionError = Class.new(StandardError)
+
+ included do
+ before_destroy do
+ raise ForbiddenActionError, '`destroy` and `destroy_all` are forbbiden. Please use `fast_destroy_all`'
+ end
+ end
+
+ class_methods do
+ ##
+ # This method delete rows and associated external data efficiently
+ #
+ # This method can replace `destroy` and `destroy_all` without having `after_destroy` hook
+ def fast_destroy_all
+ params = begin_fast_destroy
+
+ delete_all
+
+ finalize_fast_destroy(params)
+ end
+
+ ##
+ # This method returns identifiers to delete associated external data (e.g. file paths, redis keys)
+ #
+ # This method must be defined in fast destroyable model
+ def begin_fast_destroy
+ raise NotImplementedError
+ end
+
+ ##
+ # This method deletes associated external data with the identifiers returned by `begin_fast_destroy`
+ #
+ # This method must be defined in fast destroyable model
+ def finalize_fast_destroy(params)
+ raise NotImplementedError
+ end
+ end
+
+ module Helpers
+ extend ActiveSupport::Concern
+
+ class_methods do
+ ##
+ # This method is to be defined on models which have fast destroyable models as children,
+ # and let us avoid to use `dependent: :destroy` hook
+ def use_fast_destroy(relation)
+ before_destroy(prepend: true) do
+ perform_fast_destroy(public_send(relation)) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+
+ def perform_fast_destroy(subject)
+ params = subject.begin_fast_destroy
+
+ run_after_commit do
+ subject.finalize_fast_destroy(params)
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb
index e48bc0be410..01b1ef9f82c 100644
--- a/app/models/concerns/participable.rb
+++ b/app/models/concerns/participable.rb
@@ -98,6 +98,10 @@ module Participable
participants.merge(ext.users)
+ filter_by_ability(participants)
+ end
+
+ def filter_by_ability(participants)
case self
when PersonalSnippet
Ability.users_that_can_read_personal_snippet(participants.to_a, self)
diff --git a/app/models/group.rb b/app/models/group.rb
index f493836a92e..cefca316399 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -241,6 +241,13 @@ class Group < Namespace
.where(source_id: self_and_descendants.reorder(nil).select(:id))
end
+ # Returns all members that are part of the group, it's subgroups, and ancestor groups
+ def direct_and_indirect_members
+ GroupMember
+ .active_without_invites_and_requests
+ .where(source_id: self_and_hierarchy.reorder(nil).select(:id))
+ end
+
def users_with_parents
User
.where(id: members_with_parents.select(:user_id))
@@ -253,6 +260,30 @@ class Group < Namespace
.reorder(nil)
end
+ # Returns all users that are members of the group because:
+ # 1. They belong to the group
+ # 2. They belong to a project that belongs to the group
+ # 3. They belong to a sub-group or project in such sub-group
+ # 4. They belong to an ancestor group
+ def direct_and_indirect_users
+ union = Gitlab::SQL::Union.new([
+ User
+ .where(id: direct_and_indirect_members.select(:user_id))
+ .reorder(nil),
+ project_users_with_descendants
+ ])
+
+ User.from("(#{union.to_sql}) #{User.table_name}")
+ end
+
+ # Returns all users that are members of projects
+ # belonging to the current group or sub-groups
+ def project_users_with_descendants
+ User
+ .joins(projects: :group)
+ .where(namespaces: { id: self_and_descendants.select(:id) })
+ end
+
def max_member_access_for_user(user)
return GroupMember::OWNER if user.admin?
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 5621eeba7c4..3dad4277713 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -166,6 +166,13 @@ class Namespace < ActiveRecord::Base
projects.with_shared_runners.any?
end
+ # Returns all ancestors, self, and descendants of the current namespace.
+ def self_and_hierarchy
+ Gitlab::GroupHierarchy
+ .new(self.class.where(id: id))
+ .all_groups
+ end
+
# Returns all the ancestors of the current namespaces.
def ancestors
return self.class.none unless parent_id
diff --git a/app/models/project.rb b/app/models/project.rb
index b12b694aabd..0a549d843f3 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -22,6 +22,7 @@ class Project < ActiveRecord::Base
include DeploymentPlatform
include ::Gitlab::Utils::StrongMemoize
include ChronicDurationAttribute
+ include FastDestroyAll::Helpers
extend Gitlab::ConfigHelper
@@ -64,6 +65,9 @@ class Project < ActiveRecord::Base
default_value_for :only_allow_merge_if_all_discussions_are_resolved, false
add_authentication_token_field :runners_token
+
+ before_validation :mark_remote_mirrors_for_removal, if: -> { ActiveRecord::Base.connection.table_exists?(:remote_mirrors) }
+
before_save :ensure_runners_token
after_save :update_project_statistics, if: :namespace_id_changed?
@@ -81,6 +85,9 @@ class Project < ActiveRecord::Base
after_update :update_forks_visibility_level
before_destroy :remove_private_deploy_keys
+
+ use_fast_destroy :build_trace_chunks
+
after_destroy -> { run_after_commit { remove_pages } }
after_destroy :remove_exports
@@ -225,6 +232,7 @@ class Project < ActiveRecord::Base
# still using `dependent: :destroy` here.
has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName'
+ has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks
has_many :runner_projects, class_name: 'Ci::RunnerProject'
has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
has_many :variables, class_name: 'Ci::Variable'
@@ -241,11 +249,17 @@ class Project < ActiveRecord::Base
has_many :project_badges, class_name: 'ProjectBadge'
has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting', inverse_of: :project, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :remote_mirrors, inverse_of: :project
+
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
accepts_nested_attributes_for :import_data
accepts_nested_attributes_for :auto_devops, update_only: true
+ accepts_nested_attributes_for :remote_mirrors,
+ allow_destroy: true,
+ reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? }
+
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :members, to: :team, prefix: true
delegate :add_user, :add_users, to: :team
@@ -335,6 +349,7 @@ class Project < ActiveRecord::Base
scope :with_issues_enabled, -> { with_feature_enabled(:issues) }
scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) }
scope :with_merge_requests_enabled, -> { with_feature_enabled(:merge_requests) }
+ scope :with_remote_mirrors, -> { joins(:remote_mirrors).where(remote_mirrors: { enabled: true }).distinct }
scope :with_group_runners_enabled, -> do
joins(:ci_cd_settings)
@@ -754,6 +769,37 @@ class Project < ActiveRecord::Base
import_type == 'gitea'
end
+ def has_remote_mirror?
+ remote_mirror_available? && remote_mirrors.enabled.exists?
+ end
+
+ def updating_remote_mirror?
+ remote_mirrors.enabled.started.exists?
+ end
+
+ def update_remote_mirrors
+ return unless remote_mirror_available?
+
+ remote_mirrors.enabled.each(&:sync)
+ end
+
+ def mark_stuck_remote_mirrors_as_failed!
+ remote_mirrors.stuck.update_all(
+ update_status: :failed,
+ last_error: 'The remote mirror took to long to complete.',
+ last_update_at: Time.now
+ )
+ end
+
+ def mark_remote_mirrors_for_removal
+ remote_mirrors.each(&:mark_for_delete_if_blank_url)
+ end
+
+ def remote_mirror_available?
+ remote_mirror_available_overridden ||
+ ::Gitlab::CurrentSettings.mirror_available
+ end
+
def check_limit
unless creator.can_create_project? || namespace.kind == 'group'
projects_limit = creator.projects_limit
diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb
new file mode 100644
index 00000000000..bbf8fd9c6a7
--- /dev/null
+++ b/app/models/remote_mirror.rb
@@ -0,0 +1,219 @@
+class RemoteMirror < ActiveRecord::Base
+ include AfterCommitQueue
+
+ PROTECTED_BACKOFF_DELAY = 1.minute
+ UNPROTECTED_BACKOFF_DELAY = 5.minutes
+
+ attr_encrypted :credentials,
+ key: Gitlab::Application.secrets.db_key_base,
+ marshal: true,
+ encode: true,
+ mode: :per_attribute_iv_and_salt,
+ insecure_mode: true,
+ algorithm: 'aes-256-cbc'
+
+ default_value_for :only_protected_branches, true
+
+ belongs_to :project, inverse_of: :remote_mirrors
+
+ validates :url, presence: true, url: { protocols: %w(ssh git http https), allow_blank: true }
+ validates :url, addressable_url: true, if: :url_changed?
+
+ before_save :set_new_remote_name, if: :mirror_url_changed?
+
+ after_save :set_override_remote_mirror_available, unless: -> { Gitlab::CurrentSettings.current_application_settings.mirror_available }
+ after_save :refresh_remote, if: :mirror_url_changed?
+ after_update :reset_fields, if: :mirror_url_changed?
+
+ after_commit :remove_remote, on: :destroy
+
+ scope :enabled, -> { where(enabled: true) }
+ scope :started, -> { with_update_status(:started) }
+ scope :stuck, -> { started.where('last_update_at < ? OR (last_update_at IS NULL AND updated_at < ?)', 1.day.ago, 1.day.ago) }
+
+ state_machine :update_status, initial: :none do
+ event :update_start do
+ transition [:none, :finished, :failed] => :started
+ end
+
+ event :update_finish do
+ transition started: :finished
+ end
+
+ event :update_fail do
+ transition started: :failed
+ end
+
+ state :started
+ state :finished
+ state :failed
+
+ after_transition any => :started do |remote_mirror, _|
+ Gitlab::Metrics.add_event(:remote_mirrors_running, path: remote_mirror.project.full_path)
+
+ remote_mirror.update(last_update_started_at: Time.now)
+ end
+
+ after_transition started: :finished do |remote_mirror, _|
+ Gitlab::Metrics.add_event(:remote_mirrors_finished, path: remote_mirror.project.full_path)
+
+ timestamp = Time.now
+ remote_mirror.update_attributes!(
+ last_update_at: timestamp, last_successful_update_at: timestamp, last_error: nil
+ )
+ end
+
+ after_transition started: :failed do |remote_mirror, _|
+ Gitlab::Metrics.add_event(:remote_mirrors_failed, path: remote_mirror.project.full_path)
+
+ remote_mirror.update(last_update_at: Time.now)
+ end
+ end
+
+ def remote_name
+ super || fallback_remote_name
+ end
+
+ def update_failed?
+ update_status == 'failed'
+ end
+
+ def update_in_progress?
+ update_status == 'started'
+ end
+
+ def update_repository(options)
+ raw.update(options)
+ end
+
+ def sync?
+ enabled?
+ end
+
+ def sync
+ return unless sync?
+
+ if recently_scheduled?
+ RepositoryUpdateRemoteMirrorWorker.perform_in(backoff_delay, self.id, Time.now)
+ else
+ RepositoryUpdateRemoteMirrorWorker.perform_async(self.id, Time.now)
+ end
+ end
+
+ def enabled
+ return false unless project && super
+ return false unless project.remote_mirror_available?
+ return false unless project.repository_exists?
+ return false if project.pending_delete?
+
+ true
+ end
+ alias_method :enabled?, :enabled
+
+ def updated_since?(timestamp)
+ last_update_started_at && last_update_started_at > timestamp && !update_failed?
+ end
+
+ def mark_for_delete_if_blank_url
+ mark_for_destruction if url.blank?
+ end
+
+ def mark_as_failed(error_message)
+ update_fail
+ update_column(:last_error, Gitlab::UrlSanitizer.sanitize(error_message))
+ end
+
+ def url=(value)
+ super(value) && return unless Gitlab::UrlSanitizer.valid?(value)
+
+ mirror_url = Gitlab::UrlSanitizer.new(value)
+ self.credentials = mirror_url.credentials
+
+ super(mirror_url.sanitized_url)
+ end
+
+ def url
+ if super
+ Gitlab::UrlSanitizer.new(super, credentials: credentials).full_url
+ end
+ rescue
+ super
+ end
+
+ def safe_url
+ return if url.nil?
+
+ result = URI.parse(url)
+ result.password = '*****' if result.password
+ result.user = '*****' if result.user && result.user != "git" # tokens or other data may be saved as user
+ result.to_s
+ end
+
+ private
+
+ def raw
+ @raw ||= Gitlab::Git::RemoteMirror.new(project.repository.raw, remote_name)
+ end
+
+ def fallback_remote_name
+ return unless id
+
+ "remote_mirror_#{id}"
+ end
+
+ def recently_scheduled?
+ return false unless self.last_update_started_at
+
+ self.last_update_started_at >= Time.now - backoff_delay
+ end
+
+ def backoff_delay
+ if self.only_protected_branches
+ PROTECTED_BACKOFF_DELAY
+ else
+ UNPROTECTED_BACKOFF_DELAY
+ end
+ end
+
+ def reset_fields
+ update_columns(
+ last_error: nil,
+ last_update_at: nil,
+ last_successful_update_at: nil,
+ update_status: 'finished'
+ )
+ end
+
+ def set_override_remote_mirror_available
+ enabled = read_attribute(:enabled)
+
+ project.update(remote_mirror_available_overridden: enabled)
+ end
+
+ def set_new_remote_name
+ self.remote_name = "remote_mirror_#{SecureRandom.hex}"
+ end
+
+ def refresh_remote
+ return unless project
+
+ # Before adding a new remote we have to delete the data from
+ # the previous remote name
+ prev_remote_name = remote_name_was || fallback_remote_name
+ run_after_commit do
+ project.repository.async_remove_remote(prev_remote_name)
+ end
+
+ project.repository.add_remote(remote_name, url)
+ end
+
+ def remove_remote
+ return unless project # could be pending to delete so don't need to touch the git repository
+
+ project.repository.async_remove_remote(remote_name)
+ end
+
+ def mirror_url_changed?
+ url_changed? || encrypted_credentials_changed?
+ end
+end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 6831305fb93..b75c4aca982 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -854,13 +854,27 @@ class Repository
add_remote(remote_name, url, mirror_refmap: refmap)
fetch_remote(remote_name, forced: forced, prune: prune)
ensure
- remove_remote(remote_name) if tmp_remote_name
+ async_remove_remote(remote_name) if tmp_remote_name
end
def fetch_remote(remote, forced: false, ssh_auth: nil, no_tags: false, prune: true)
gitlab_shell.fetch_remote(raw_repository, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, prune: prune)
end
+ def async_remove_remote(remote_name)
+ return unless remote_name
+
+ job_id = RepositoryRemoveRemoteWorker.perform_async(project.id, remote_name)
+
+ if job_id
+ Rails.logger.info("Remove remote job scheduled for #{project.id} with remote name: #{remote_name} job ID #{job_id}.")
+ else
+ Rails.logger.info("Remove remote job failed to create for #{project.id} with remote name #{remote_name}.")
+ end
+
+ job_id
+ end
+
def fetch_source_branch!(source_repository, source_branch, local_ref)
raw_repository.fetch_source_branch!(source_repository.raw_repository, source_branch, local_ref)
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 3529d0aa60c..5759b1a376f 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -80,6 +80,11 @@ class ProjectPolicy < BasePolicy
project.merge_requests_allowing_push_to_user(user).any?
end
+ with_scope :global
+ condition(:mirror_available, score: 0) do
+ ::Gitlab::CurrentSettings.current_application_settings.mirror_available
+ end
+
# We aren't checking `:read_issue` or `:read_merge_request` in this case
# because it could be possible for a user to see an issuable-iid
# (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be
@@ -246,6 +251,8 @@ class ProjectPolicy < BasePolicy
enable :create_cluster
end
+ rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror
+
rule { archived }.policy do
prevent :push_code
prevent :push_to_delete_protected_branch
diff --git a/app/serializers/project_mirror_entity.rb b/app/serializers/project_mirror_entity.rb
new file mode 100644
index 00000000000..a9c08ac021a
--- /dev/null
+++ b/app/serializers/project_mirror_entity.rb
@@ -0,0 +1,11 @@
+class ProjectMirrorEntity < Grape::Entity
+ expose :id
+
+ expose :remote_mirrors_attributes do |project|
+ next [] unless project.remote_mirrors.present?
+
+ project.remote_mirrors.map do |remote|
+ remote.as_json(only: %i[id url enabled])
+ end
+ end
+end
diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb
index 4523b15152e..2516df70ad9 100644
--- a/app/serializers/stage_entity.rb
+++ b/app/serializers/stage_entity.rb
@@ -11,6 +11,12 @@ class StageEntity < Grape::Entity
if: -> (_, opts) { opts[:grouped] },
with: JobGroupEntity
+ expose :latest_statuses,
+ if: -> (_, opts) { opts[:details] },
+ with: JobEntity do |stage|
+ latest_statuses
+ end
+
expose :detailed_status, as: :status, with: StatusEntity
expose :path do |stage|
@@ -35,4 +41,14 @@ class StageEntity < Grape::Entity
def detailed_status
stage.detailed_status(request.current_user)
end
+
+ def grouped_statuses
+ @grouped_statuses ||= stage.statuses.latest_ordered.group_by(&:status)
+ end
+
+ def latest_statuses
+ HasStatus::ORDERED_STATUSES.map do |ordered_status|
+ grouped_statuses.fetch(ordered_status, [])
+ end.flatten
+ end
end
diff --git a/app/serializers/stage_serializer.rb b/app/serializers/stage_serializer.rb
new file mode 100644
index 00000000000..091d8e91e43
--- /dev/null
+++ b/app/serializers/stage_serializer.rb
@@ -0,0 +1,7 @@
+class StageSerializer < BaseSerializer
+ include WithPagination
+
+ InvalidResourceError = Class.new(StandardError)
+
+ entity StageEntity
+end
diff --git a/app/services/concerns/exclusive_lease_guard.rb b/app/services/concerns/exclusive_lease_guard.rb
new file mode 100644
index 00000000000..30be6accc32
--- /dev/null
+++ b/app/services/concerns/exclusive_lease_guard.rb
@@ -0,0 +1,52 @@
+#
+# Concern that helps with getting an exclusive lease for running a block
+# of code.
+#
+# `#try_obtain_lease` takes a block which will be run if it was able to
+# obtain the lease. Implement `#lease_timeout` to configure the timeout
+# for the exclusive lease. Optionally override `#lease_key` to set the
+# lease key, it defaults to the class name with underscores.
+#
+module ExclusiveLeaseGuard
+ extend ActiveSupport::Concern
+
+ def try_obtain_lease
+ lease = exclusive_lease.try_obtain
+
+ unless lease
+ log_error('Cannot obtain an exclusive lease. There must be another instance already in execution.')
+ return
+ end
+
+ begin
+ yield lease
+ ensure
+ release_lease(lease)
+ end
+ end
+
+ def exclusive_lease
+ @lease ||= Gitlab::ExclusiveLease.new(lease_key, timeout: lease_timeout)
+ end
+
+ def lease_key
+ @lease_key ||= self.class.name.underscore
+ end
+
+ def lease_timeout
+ raise NotImplementedError,
+ "#{self.class.name} does not implement #{__method__}"
+ end
+
+ def release_lease(uuid)
+ Gitlab::ExclusiveLease.cancel(lease_key, uuid)
+ end
+
+ def renew_lease!
+ exclusive_lease.renew
+ end
+
+ def log_error(message, extra_args = {})
+ logger.error(message)
+ end
+end
diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb
new file mode 100644
index 00000000000..bf60b96938d
--- /dev/null
+++ b/app/services/concerns/users/participable_service.rb
@@ -0,0 +1,41 @@
+module Users
+ module ParticipableService
+ extend ActiveSupport::Concern
+
+ included do
+ attr_reader :noteable
+ end
+
+ def noteable_owner
+ return [] unless noteable && noteable.author.present?
+
+ [as_hash(noteable.author)]
+ end
+
+ def participants_in_noteable
+ return [] unless noteable
+
+ users = noteable.participants(current_user)
+ sorted(users)
+ end
+
+ def sorted(users)
+ users.uniq.to_a.compact.sort_by(&:username).map do |user|
+ as_hash(user)
+ end
+ end
+
+ def groups
+ current_user.authorized_groups.sort_by(&:path).map do |group|
+ count = group.users.count
+ { username: group.full_path, name: group.full_name, count: count, avatar_url: group.avatar_url }
+ end
+ end
+
+ private
+
+ def as_hash(user)
+ { username: user.username, name: user.name, avatar_url: user.avatar_url }
+ end
+ end
+end
diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb
index c037141fcde..f3bfc53dcd3 100644
--- a/app/services/git_push_service.rb
+++ b/app/services/git_push_service.rb
@@ -55,6 +55,7 @@ class GitPushService < BaseService
execute_related_hooks
perform_housekeeping
+ update_remote_mirrors
update_caches
update_signatures
@@ -119,6 +120,13 @@ class GitPushService < BaseService
protected
+ def update_remote_mirrors
+ return unless @project.has_remote_mirror?
+
+ @project.mark_stuck_remote_mirrors_as_failed!
+ @project.update_remote_mirrors
+ end
+
def execute_related_hooks
# Update merge requests that may be affected by this push. A new branch
# could cause the last commit of a merge request to change.
diff --git a/app/services/projects/participants_service.rb b/app/services/projects/participants_service.rb
index e6193fcacee..eb0472c6024 100644
--- a/app/services/projects/participants_service.rb
+++ b/app/services/projects/participants_service.rb
@@ -1,6 +1,6 @@
module Projects
class ParticipantsService < BaseService
- attr_reader :noteable
+ include Users::ParticipableService
def execute(noteable)
@noteable = noteable
@@ -10,36 +10,6 @@ module Projects
participants.uniq
end
- def noteable_owner
- return [] unless noteable && noteable.author.present?
-
- [{
- name: noteable.author.name,
- username: noteable.author.username,
- avatar_url: noteable.author.avatar_url
- }]
- end
-
- def participants_in_noteable
- return [] unless noteable
-
- users = noteable.participants(current_user)
- sorted(users)
- end
-
- def sorted(users)
- users.uniq.to_a.compact.sort_by(&:username).map do |user|
- { username: user.username, name: user.name, avatar_url: user.avatar_url }
- end
- end
-
- def groups
- current_user.authorized_groups.sort_by(&:path).map do |group|
- count = group.users.count
- { username: group.full_path, name: group.full_name, count: count, avatar_url: group.avatar_url }
- end
- end
-
def all_members
count = project.team.members.flatten.count
[{ username: "all", name: "All Project and Group Members", count: count }]
diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb
new file mode 100644
index 00000000000..8183a2f26d7
--- /dev/null
+++ b/app/services/projects/update_remote_mirror_service.rb
@@ -0,0 +1,30 @@
+module Projects
+ class UpdateRemoteMirrorService < BaseService
+ attr_reader :errors
+
+ def execute(remote_mirror)
+ @errors = []
+
+ return success unless remote_mirror.enabled?
+
+ begin
+ repository.fetch_remote(remote_mirror.remote_name, no_tags: true)
+
+ opts = {}
+ if remote_mirror.only_protected_branches?
+ opts[:only_branches_matching] = project.protected_branches.select(:name).map(&:name)
+ end
+
+ remote_mirror.update_repository(opts)
+ rescue => e
+ errors << e.message.strip
+ end
+
+ if errors.present?
+ error(errors.join("\n\n"))
+ else
+ success
+ end
+ end
+ end
+end
diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml
index f33769b23c2..fe335f30a62 100644
--- a/app/views/admin/application_settings/_repository_check.html.haml
+++ b/app/views/admin/application_settings/_repository_check.html.haml
@@ -12,7 +12,7 @@
Enable Repository Checks
.help-block
GitLab will periodically run
- %a{ href: 'https://www.kernel.org/pub/software/scm/git/docs/git-fsck.html', target: 'blank' } 'git fsck'
+ %a{ href: 'https://git-scm.com/docs/git-fsck', target: 'blank' } 'git fsck'
in all project and wiki repositories to look for silent disk corruption issues.
.form-group
.col-sm-offset-2.col-sm-10
diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
new file mode 100644
index 00000000000..09183ec6260
--- /dev/null
+++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
@@ -0,0 +1,16 @@
+= form_for @application_setting, url: admin_application_settings_path, html: { class: 'form-horizontal fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :mirror_available, 'Enable mirror configuration', class: 'control-label col-sm-2'
+ .col-sm-10
+ .checkbox
+ = f.label :mirror_available do
+ = f.check_box :mirror_available
+ Allow mirrors to be setup for projects
+ %span.help-block
+ If disabled, only admins will be able to setup mirrors in projects.
+ = link_to icon('question-circle'), help_page_path('workflow/repository_mirroring')
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml
index 3c00e3c8fc4..3f440c76ee0 100644
--- a/app/views/admin/application_settings/show.html.haml
+++ b/app/views/admin/application_settings/show.html.haml
@@ -313,3 +313,14 @@
= _('Allow requests to the local network from hooks and services.')
.settings-content
= render 'outbound'
+
+%section.settings.as-mirror.no-animate#js-mirror-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Repository mirror settings')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('Configure push mirrors.')
+ .settings-content
+ = render partial: 'repository_mirrors_form'
diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml
index db2040110fa..d828f6f971d 100644
--- a/app/views/ci/status/_dropdown_graph_badge.html.haml
+++ b/app/views/ci/status/_dropdown_graph_badge.html.haml
@@ -16,5 +16,5 @@
%span.ci-build-text= subject.name
- if status.has_action?
- = link_to status.action_path, class: "ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do
+ = link_to status.action_path, class: "ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do
= sprite_icon(status.action_icon, css_class: "icon-action-#{status.action_icon}")
diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml
index 4276e6ee4bb..240e03a5d53 100644
--- a/app/views/layouts/_init_auto_complete.html.haml
+++ b/app/views/layouts/_init_auto_complete.html.haml
@@ -1,16 +1,11 @@
-- project = @target_project || @project
+- object = @target_project || @project || @group
- noteable_type = @noteable.class if @noteable.present?
-- if project
+- datasources = autocomplete_data_sources(object, noteable_type)
+
+- if object
-# haml-lint:disable InlineJavaScript
:javascript
gl = window.gl || {};
gl.GfmAutoComplete = gl.GfmAutoComplete || {};
- gl.GfmAutoComplete.dataSources = {
- members: "#{members_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}",
- issues: "#{issues_project_autocomplete_sources_path(project)}",
- mergeRequests: "#{merge_requests_project_autocomplete_sources_path(project)}",
- labels: "#{labels_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}",
- milestones: "#{milestones_project_autocomplete_sources_path(project)}",
- commands: "#{commands_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}"
- };
+ gl.GfmAutoComplete.dataSources = #{datasources.to_json};
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index ab371521840..483cca11df9 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -24,7 +24,7 @@
= link_to _("Create merge request"), create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success'
.control
- = form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form', data: { 'signatures-path' => namespace_project_signatures_path }) do
+ = form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path }) do
= search_field_tag :search, params[:search], { placeholder: _('Filter by commit message'), id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false }
.control
= link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn' do
diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml
index d0c8a699608..40cdf96e76d 100644
--- a/app/views/projects/compare/_form.html.haml
+++ b/app/views/projects/compare/_form.html.haml
@@ -1,4 +1,4 @@
-= form_tag project_compare_index_path(@project), method: :post, class: 'form-inline js-requires-input' do
+= form_tag project_compare_index_path(@project), method: :post, class: 'form-inline js-requires-input js-signature-container', data: { 'signatures-path' => signatures_namespace_project_compare_index_path } do
.clearfix
- if params[:to] && params[:from]
.compare-switch-container
diff --git a/app/views/projects/mirrors/_instructions.html.haml b/app/views/projects/mirrors/_instructions.html.haml
new file mode 100644
index 00000000000..64f0fde30cf
--- /dev/null
+++ b/app/views/projects/mirrors/_instructions.html.haml
@@ -0,0 +1,10 @@
+.account-well.prepend-top-default.append-bottom-default
+ %ul
+ %li
+ The repository must be accessible over <code>http://</code>, <code>https://</code>, <code>ssh://</code> or <code>git://</code>.
+ %li
+ Include the username in the URL if required: <code>https://username@gitlab.company.com/group/project.git</code>.
+ %li
+ The update action will time out after 10 minutes. For big repositories, use a clone/push combination.
+ %li
+ The Git LFS objects will <strong>not</strong> be synced.
diff --git a/app/views/projects/mirrors/_push.html.haml b/app/views/projects/mirrors/_push.html.haml
new file mode 100644
index 00000000000..4a6aefce351
--- /dev/null
+++ b/app/views/projects/mirrors/_push.html.haml
@@ -0,0 +1,50 @@
+- expanded = Rails.env.test?
+%section.settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ Push to a remote repository
+ %button.btn.js-settings-toggle
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ Set up the remote repository that you want to update with the content of the current repository
+ every time someone pushes to it.
+ = link_to 'Read more', help_page_path('workflow/repository_mirroring', anchor: 'pushing-to-a-remote-repository'), target: '_blank'
+ .settings-content
+ = form_for @project, url: project_mirror_path(@project) do |f|
+ %div
+ = form_errors(@project)
+ = render "shared/remote_mirror_update_button", remote_mirror: @remote_mirror
+ - if @remote_mirror.last_error.present?
+ .panel.panel-danger
+ .panel-heading
+ - if @remote_mirror.last_update_at
+ The remote repository failed to update #{time_ago_with_tooltip(@remote_mirror.last_update_at)}.
+ - else
+ The remote repository failed to update.
+
+ - if @remote_mirror.last_successful_update_at
+ Last successful update #{time_ago_with_tooltip(@remote_mirror.last_successful_update_at)}.
+ .panel-body
+ %pre
+ :preserve
+ #{h(@remote_mirror.last_error.strip)}
+ = f.fields_for :remote_mirrors, @remote_mirror do |rm_form|
+ .form-group
+ = rm_form.check_box :enabled, class: "pull-left"
+ .prepend-left-20
+ = rm_form.label :enabled, "Remote mirror repository", class: "label-light append-bottom-0"
+ %p.light.append-bottom-0
+ Automatically update the remote mirror's branches, tags, and commits from this repository every time someone pushes to it.
+ .form-group.has-feedback
+ = rm_form.label :url, "Git repository URL", class: "label-light"
+ = rm_form.text_field :url, class: "form-control", placeholder: 'https://username:password@gitlab.company.com/group/project.git'
+
+ = render "projects/mirrors/instructions"
+
+ .form-group
+ = rm_form.check_box :only_protected_branches, class: 'pull-left'
+ .prepend-left-20
+ = rm_form.label :only_protected_branches, class: 'label-light'
+ = link_to icon('question-circle'), help_page_path('user/project/protected_branches')
+
+ = f.submit 'Save changes', class: 'btn btn-create', name: 'update_remote_mirror'
diff --git a/app/views/projects/mirrors/_show.html.haml b/app/views/projects/mirrors/_show.html.haml
new file mode 100644
index 00000000000..de77701a373
--- /dev/null
+++ b/app/views/projects/mirrors/_show.html.haml
@@ -0,0 +1,3 @@
+- if can?(current_user, :admin_remote_mirror, @project)
+ = render 'projects/mirrors/push'
+
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index f57590a908f..5dda2ec28b4 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -2,6 +2,8 @@
- page_title "Repository"
- @content_class = "limit-container-width" unless fluid_layout
+= render "projects/mirrors/show"
+
-# Protected branches & tags use a lot of nested partials.
-# The shared parts of the views can be found in the `shared` directory.
-# Those are used throughout the actual views. These `shared` views are then
diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml
index 901a177323b..ac2164a4a71 100644
--- a/app/views/shared/_mini_pipeline_graph.html.haml
+++ b/app/views/shared/_mini_pipeline_graph.html.haml
@@ -6,12 +6,13 @@
- status_klass = "ci-status-icon ci-status-icon-#{detailed_status.group}"
.stage-container.dropdown{ class: klass }
- %button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_project_pipeline_path(pipeline.project, pipeline, stage: stage.name) } }
+ %button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_ajax_project_pipeline_path(pipeline.project, pipeline, stage: stage.name) } }
= sprite_icon(icon_status)
= icon('caret-down')
%ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container
%li.js-builds-dropdown-list.scrollable-menu
+ %ul
%li.js-builds-dropdown-loading.hidden
.text-center
diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml
new file mode 100644
index 00000000000..34de1c0695f
--- /dev/null
+++ b/app/views/shared/_remote_mirror_update_button.html.haml
@@ -0,0 +1,13 @@
+- if @project.has_remote_mirror?
+ .append-bottom-default
+ - if remote_mirror.update_in_progress?
+ %span.btn.disabled
+ = icon("refresh spin")
+ Updating&hellip;
+ - else
+ = link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn" do
+ = icon("refresh")
+ Update Now
+ - if @remote_mirror.last_successful_update_at
+ %p.inline.prepend-left-10
+ Successfully updated #{time_ago_with_tooltip(@remote_mirror.last_successful_update_at)}.
diff --git a/app/workers/admin_email_worker.rb b/app/workers/admin_email_worker.rb
index bec0a003a1c..044e470141e 100644
--- a/app/workers/admin_email_worker.rb
+++ b/app/workers/admin_email_worker.rb
@@ -3,6 +3,12 @@ class AdminEmailWorker
include CronjobQueue
def perform
+ send_repository_check_mail if Gitlab::CurrentSettings.repository_checks_enabled
+ end
+
+ private
+
+ def send_repository_check_mail
repository_check_failed_count = Project.where(last_repository_check_failed: true).count
return if repository_check_failed_count.zero?
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index c469aea7052..b6433eb3eff 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -52,6 +52,7 @@
- pipeline_creation:create_pipeline
- pipeline_creation:run_pipeline_schedule
- pipeline_background:archive_trace
+- pipeline_background:ci_build_trace_chunk_flush
- pipeline_default:build_coverage
- pipeline_default:build_trace_sections
- pipeline_default:pipeline_metrics
@@ -106,9 +107,11 @@
- rebase
- repository_fork
- repository_import
+- repository_remove_remote
- storage_migrator
- system_hook_push
- update_merge_requests
- update_user_activity
- upload_checksum
- web_hook
+- repository_update_remote_mirror
diff --git a/app/workers/ci/build_trace_chunk_flush_worker.rb b/app/workers/ci/build_trace_chunk_flush_worker.rb
new file mode 100644
index 00000000000..218d6688bd9
--- /dev/null
+++ b/app/workers/ci/build_trace_chunk_flush_worker.rb
@@ -0,0 +1,12 @@
+module Ci
+ class BuildTraceChunkFlushWorker
+ include ApplicationWorker
+ include PipelineBackgroundQueue
+
+ def perform(build_trace_chunk_id)
+ ::Ci::BuildTraceChunk.find_by(id: build_trace_chunk_id).try do |build_trace_chunk|
+ build_trace_chunk.use_database!
+ end
+ end
+ end
+end
diff --git a/app/workers/repository_check/batch_worker.rb b/app/workers/repository_check/batch_worker.rb
index 76688cf51c1..72f0a9b0619 100644
--- a/app/workers/repository_check/batch_worker.rb
+++ b/app/workers/repository_check/batch_worker.rb
@@ -4,8 +4,11 @@ module RepositoryCheck
include CronjobQueue
RUN_TIME = 3600
+ BATCH_SIZE = 10_000
def perform
+ return unless Gitlab::CurrentSettings.repository_checks_enabled
+
start = Time.now
# This loop will break after a little more than one hour ('a little
@@ -15,7 +18,6 @@ module RepositoryCheck
# check, only one (or two) will be checked at a time.
project_ids.each do |project_id|
break if Time.now - start >= RUN_TIME
- break unless current_settings.repository_checks_enabled
next unless try_obtain_lease(project_id)
@@ -31,12 +33,20 @@ module RepositoryCheck
# getting ID's from Postgres is not terribly slow, and because no user
# has to sit and wait for this query to finish.
def project_ids
- limit = 10_000
- never_checked_projects = Project.where('last_repository_check_at IS NULL AND created_at < ?', 24.hours.ago)
- .limit(limit).pluck(:id)
- old_check_projects = Project.where('last_repository_check_at < ?', 1.month.ago)
- .reorder('last_repository_check_at ASC').limit(limit).pluck(:id)
- never_checked_projects + old_check_projects
+ never_checked_project_ids(BATCH_SIZE) + old_checked_project_ids(BATCH_SIZE)
+ end
+
+ def never_checked_project_ids(batch_size)
+ Project.where(last_repository_check_at: nil)
+ .where('created_at < ?', 24.hours.ago)
+ .limit(batch_size).pluck(:id)
+ end
+
+ def old_checked_project_ids(batch_size)
+ Project.where.not(last_repository_check_at: nil)
+ .where('last_repository_check_at < ?', 1.month.ago)
+ .reorder(last_repository_check_at: :asc)
+ .limit(batch_size).pluck(:id)
end
def try_obtain_lease(id)
@@ -47,16 +57,5 @@ module RepositoryCheck
timeout: 24.hours
).try_obtain
end
-
- def current_settings
- # No caching of the settings! If we cache them and an admin disables
- # this feature, an active RepositoryCheckWorker would keep going for up
- # to 1 hour after the feature was disabled.
- if Rails.env.test?
- Gitlab::CurrentSettings.fake_application_settings
- else
- ApplicationSetting.current
- end
- end
end
end
diff --git a/app/workers/repository_check/single_repository_worker.rb b/app/workers/repository_check/single_repository_worker.rb
index 116bc185b38..3cffb8b14e4 100644
--- a/app/workers/repository_check/single_repository_worker.rb
+++ b/app/workers/repository_check/single_repository_worker.rb
@@ -5,27 +5,34 @@ module RepositoryCheck
def perform(project_id)
project = Project.find(project_id)
+ healthy = project_healthy?(project)
+
+ update_repository_check_status(project, healthy)
+ end
+
+ private
+
+ def update_repository_check_status(project, healthy)
project.update_columns(
- last_repository_check_failed: !check(project),
+ last_repository_check_failed: !healthy,
last_repository_check_at: Time.now
)
end
- private
+ def project_healthy?(project)
+ repo_healthy?(project) && wiki_repo_healthy?(project)
+ end
- def check(project)
- if has_pushes?(project) && !git_fsck(project.repository)
- false
- elsif project.wiki_enabled?
- # Historically some projects never had their wiki repos initialized;
- # this happens on project creation now. Let's initialize an empty repo
- # if it is not already there.
- project.create_wiki
+ def repo_healthy?(project)
+ return true unless has_changes?(project)
- git_fsck(project.wiki.repository)
- else
- true
- end
+ git_fsck(project.repository)
+ end
+
+ def wiki_repo_healthy?(project)
+ return true unless has_wiki_changes?(project)
+
+ git_fsck(project.wiki.repository)
end
def git_fsck(repository)
@@ -39,8 +46,19 @@ module RepositoryCheck
false
end
- def has_pushes?(project)
+ def has_changes?(project)
Project.with_push.exists?(project.id)
end
+
+ def has_wiki_changes?(project)
+ return false unless project.wiki_enabled?
+
+ # Historically some projects never had their wiki repos initialized;
+ # this happens on project creation now. Let's initialize an empty repo
+ # if it is not already there.
+ return false unless project.create_wiki
+
+ has_changes?(project)
+ end
end
end
diff --git a/app/workers/repository_remove_remote_worker.rb b/app/workers/repository_remove_remote_worker.rb
new file mode 100644
index 00000000000..1c19b604b77
--- /dev/null
+++ b/app/workers/repository_remove_remote_worker.rb
@@ -0,0 +1,35 @@
+class RepositoryRemoveRemoteWorker
+ include ApplicationWorker
+ include ExclusiveLeaseGuard
+
+ LEASE_TIMEOUT = 1.hour
+
+ attr_reader :project, :remote_name
+
+ def perform(project_id, remote_name)
+ @remote_name = remote_name
+ @project = Project.find_by_id(project_id)
+
+ return unless @project
+
+ logger.info("Removing remote #{remote_name} from project #{project.id}")
+
+ try_obtain_lease do
+ remove_remote = @project.repository.remove_remote(remote_name)
+
+ if remove_remote
+ logger.info("Remote #{remote_name} was successfully removed from project #{project.id}")
+ else
+ logger.error("Could not remove remote #{remote_name} from project #{project.id}")
+ end
+ end
+ end
+
+ def lease_timeout
+ LEASE_TIMEOUT
+ end
+
+ def lease_key
+ "remove_remote_#{project.id}_#{remote_name}"
+ end
+end
diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb
new file mode 100644
index 00000000000..bb963979e88
--- /dev/null
+++ b/app/workers/repository_update_remote_mirror_worker.rb
@@ -0,0 +1,49 @@
+class RepositoryUpdateRemoteMirrorWorker
+ UpdateAlreadyInProgressError = Class.new(StandardError)
+ UpdateError = Class.new(StandardError)
+
+ include ApplicationWorker
+ include Gitlab::ShellAdapter
+
+ sidekiq_options retry: 3, dead: false
+
+ sidekiq_retry_in { |count| 30 * count }
+
+ sidekiq_retries_exhausted do |msg, _|
+ Sidekiq.logger.warn "Failed #{msg['class']} with #{msg['args']}: #{msg['error_message']}"
+ end
+
+ def perform(remote_mirror_id, scheduled_time)
+ remote_mirror = RemoteMirror.find(remote_mirror_id)
+ return if remote_mirror.updated_since?(scheduled_time)
+
+ raise UpdateAlreadyInProgressError if remote_mirror.update_in_progress?
+
+ remote_mirror.update_start
+
+ project = remote_mirror.project
+ current_user = project.creator
+ result = Projects::UpdateRemoteMirrorService.new(project, current_user).execute(remote_mirror)
+ raise UpdateError, result[:message] if result[:status] == :error
+
+ remote_mirror.update_finish
+ rescue UpdateAlreadyInProgressError
+ raise
+ rescue UpdateError => ex
+ fail_remote_mirror(remote_mirror, ex.message)
+ raise
+ rescue => ex
+ return unless remote_mirror
+
+ fail_remote_mirror(remote_mirror, ex.message)
+ raise UpdateError, "#{ex.class}: #{ex.message}"
+ end
+
+ private
+
+ def fail_remote_mirror(remote_mirror, message)
+ remote_mirror.mark_as_failed(message)
+
+ Rails.logger.error(message)
+ end
+end
diff --git a/changelogs/unreleased/33697-pipelines-json-endpoint.yml b/changelogs/unreleased/33697-pipelines-json-endpoint.yml
new file mode 100644
index 00000000000..d44e2729415
--- /dev/null
+++ b/changelogs/unreleased/33697-pipelines-json-endpoint.yml
@@ -0,0 +1,5 @@
+---
+title: Use VueJS for rendering pipeline stages
+merge_request:
+author:
+type: changed
diff --git a/changelogs/unreleased/42099-port-push-mirroring-to-ce-ce-port-v-2.yml b/changelogs/unreleased/42099-port-push-mirroring-to-ce-ce-port-v-2.yml
new file mode 100644
index 00000000000..f23521ea416
--- /dev/null
+++ b/changelogs/unreleased/42099-port-push-mirroring-to-ce-ce-port-v-2.yml
@@ -0,0 +1,5 @@
+---
+title: Adds push mirrors to GitLab Community Edition
+merge_request: 18715
+author:
+type: changed
diff --git a/changelogs/unreleased/44879.yml b/changelogs/unreleased/44879.yml
new file mode 100644
index 00000000000..b51e057bb7b
--- /dev/null
+++ b/changelogs/unreleased/44879.yml
@@ -0,0 +1,5 @@
+---
+title: Add the signature verfication badge to the compare view
+merge_request: 18245
+author: Marc Shaw
+type: added
diff --git a/changelogs/unreleased/issue_43660.yml b/changelogs/unreleased/issue_43660.yml
new file mode 100644
index 00000000000..d83c0ebcbb5
--- /dev/null
+++ b/changelogs/unreleased/issue_43660.yml
@@ -0,0 +1,5 @@
+---
+title: Enable prometheus monitoring by default
+merge_request:
+author:
+type: other
diff --git a/changelogs/unreleased/live-trace-v2-efficient-destroy-all.yml b/changelogs/unreleased/live-trace-v2-efficient-destroy-all.yml
new file mode 100644
index 00000000000..ab22739b73d
--- /dev/null
+++ b/changelogs/unreleased/live-trace-v2-efficient-destroy-all.yml
@@ -0,0 +1,5 @@
+---
+title: Destroy build_chunks efficiently with FastDestroyAll module
+merge_request: 18575
+author:
+type: performance
diff --git a/changelogs/unreleased/live-trace-v2.yml b/changelogs/unreleased/live-trace-v2.yml
new file mode 100644
index 00000000000..875a66bc565
--- /dev/null
+++ b/changelogs/unreleased/live-trace-v2.yml
@@ -0,0 +1,5 @@
+---
+title: New CI Job live-trace architecture
+merge_request: 18169
+author:
+type: changed
diff --git a/changelogs/unreleased/tc-repo-verify-mails.yml b/changelogs/unreleased/tc-repo-verify-mails.yml
new file mode 100644
index 00000000000..b4d3c4b1596
--- /dev/null
+++ b/changelogs/unreleased/tc-repo-verify-mails.yml
@@ -0,0 +1,5 @@
+---
+title: Small improvements to repository checks
+merge_request: 18484
+author:
+type: changed
diff --git a/changelogs/unreleased/tz-upgrade-underscore.yml b/changelogs/unreleased/tz-upgrade-underscore.yml
new file mode 100644
index 00000000000..5dfd8154ecd
--- /dev/null
+++ b/changelogs/unreleased/tz-upgrade-underscore.yml
@@ -0,0 +1,5 @@
+---
+title: Upgrade underscore.js to 1.9.0
+merge_request: 18578
+author:
+type: other
diff --git a/config/initializers/8_metrics.rb b/config/initializers/8_metrics.rb
index 7cdf49159b4..8a851b89c56 100644
--- a/config/initializers/8_metrics.rb
+++ b/config/initializers/8_metrics.rb
@@ -119,7 +119,14 @@ def instrument_classes(instrumentation)
end
# rubocop:enable Metrics/AbcSize
-if Gitlab::Metrics.enabled?
+# With prometheus enabled by default this breaks all specs
+# that stubs methods using `any_instance_of` for the models reloaded here.
+#
+# We should deprecate the usage of `any_instance_of` in the future
+# check: https://github.com/rspec/rspec-mocks#settings-mocks-or-stubs-on-any-instance-of-a-class
+#
+# Related issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/33587
+if Gitlab::Metrics.enabled? && !Rails.env.test?
require 'pathname'
require 'influxdb'
require 'connection_pool'
diff --git a/config/initializers/warden.rb b/config/initializers/warden.rb
index bf079f8e1a7..8cc36820d3c 100644
--- a/config/initializers/warden.rb
+++ b/config/initializers/warden.rb
@@ -1,21 +1,21 @@
Rails.application.configure do |config|
- Warden::Manager.after_set_user do |user, auth, opts|
+ Warden::Manager.after_set_user(scope: :user) do |user, auth, opts|
Gitlab::Auth::UniqueIpsLimiter.limit_user!(user)
end
- Warden::Manager.before_failure do |env, opts|
+ Warden::Manager.before_failure(scope: :user) do |env, opts|
Gitlab::Auth::BlockedUserTracker.log_if_user_blocked(env)
end
- Warden::Manager.after_authentication do |user, auth, opts|
+ Warden::Manager.after_authentication(scope: :user) do |user, auth, opts|
ActiveSession.cleanup(user)
end
- Warden::Manager.after_set_user only: :fetch do |user, auth, opts|
+ Warden::Manager.after_set_user(scope: :user, only: :fetch) do |user, auth, opts|
ActiveSession.set(user, auth.request)
end
- Warden::Manager.before_logout do |user, auth, opts|
+ Warden::Manager.before_logout(scope: :user) do |user, auth, opts|
ActiveSession.destroy(user || auth.user, auth.request.session.id)
end
end
diff --git a/config/prometheus/additional_metrics.yml b/config/prometheus/additional_metrics.yml
index 10ca612b246..13732384953 100644
--- a/config/prometheus/additional_metrics.yml
+++ b/config/prometheus/additional_metrics.yml
@@ -103,10 +103,10 @@
- title: "Throughput"
y_label: "Requests / Sec"
required_metrics:
- - nginx_responses_total
+ - nginx_server_requests
weight: 1
queries:
- - query_range: 'sum(rate(nginx_responses_total{server_zone!="*", server_zone!="_", %{environment_filter}}[2m])) by (status_code)'
+ - query_range: 'sum(rate(nginx_server_requests{server_zone!="*", server_zone!="_", %{environment_filter}}[2m])) by (code)'
unit: req / sec
label: Status Code
series:
@@ -121,19 +121,19 @@
- title: "Latency"
y_label: "Latency (ms)"
required_metrics:
- - nginx_upstream_response_msecs_avg
+ - nginx_server_requestMsec
weight: 1
queries:
- - query_range: 'avg(nginx_upstream_response_msecs_avg{%{environment_filter}})'
+ - query_range: 'avg(nginx_server_requestMsec{%{environment_filter}})'
label: Upstream
unit: ms
- title: "HTTP Error Rate"
y_label: "HTTP 500 Errors / Sec"
required_metrics:
- - nginx_responses_total
+ - nginx_server_requests
weight: 1
queries:
- - query_range: 'sum(rate(nginx_responses_total{status_code="5xx", %{environment_filter}}[2m]))'
+ - query_range: 'sum(rate(nginx_server_requests{code="5xx", %{environment_filter}}[2m]))'
label: HTTP Errors
unit: "errors / sec"
- group: System metrics (Kubernetes)
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 7fffd16f3cf..5a1be1a8b73 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -174,6 +174,12 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
+ resource :mirror, only: [:show, :update] do
+ member do
+ post :update_now
+ end
+ end
+
resources :pipelines, only: [:index, :new, :create, :show] do
collection do
resource :pipelines_settings, path: 'settings', only: [:show, :update]
@@ -182,6 +188,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
member do
get :stage
+ get :stage_ajax
post :cancel
post :retry
get :builds
diff --git a/config/routes/repository.rb b/config/routes/repository.rb
index 9e506a1a43a..e2bf8d6a7ff 100644
--- a/config/routes/repository.rb
+++ b/config/routes/repository.rb
@@ -18,6 +18,7 @@ scope format: false do
resources :compare, only: [:index, :create] do
collection do
get :diff_for_path
+ get :signatures
end
end
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index 47fbbed44cf..e1e8f36b663 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -73,3 +73,6 @@
- [object_storage, 1]
- [plugin, 1]
- [pipeline_background, 1]
+ - [repository_update_remote_mirror, 1]
+ - [repository_remove_remote, 1]
+
diff --git a/db/migrate/20180326202229_create_ci_build_trace_chunks.rb b/db/migrate/20180326202229_create_ci_build_trace_chunks.rb
new file mode 100644
index 00000000000..fb3f5786e85
--- /dev/null
+++ b/db/migrate/20180326202229_create_ci_build_trace_chunks.rb
@@ -0,0 +1,17 @@
+class CreateCiBuildTraceChunks < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ create_table :ci_build_trace_chunks, id: :bigserial do |t|
+ t.integer :build_id, null: false
+ t.integer :chunk_index, null: false
+ t.integer :data_store, null: false
+ t.binary :raw_data
+
+ t.foreign_key :ci_builds, column: :build_id, on_delete: :cascade
+ t.index [:build_id, :chunk_index], unique: true
+ end
+ end
+end
diff --git a/db/migrate/20180406204716_add_limits_ci_build_trace_chunks_raw_data_for_mysql.rb b/db/migrate/20180406204716_add_limits_ci_build_trace_chunks_raw_data_for_mysql.rb
new file mode 100644
index 00000000000..0f2734853e6
--- /dev/null
+++ b/db/migrate/20180406204716_add_limits_ci_build_trace_chunks_raw_data_for_mysql.rb
@@ -0,0 +1,13 @@
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+require Rails.root.join('db/migrate/limits_ci_build_trace_chunks_raw_data_for_mysql')
+
+class AddLimitsCiBuildTraceChunksRawDataForMysql < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ LimitsCiBuildTraceChunksRawDataForMysql.new.up
+ end
+end
diff --git a/db/migrate/20180420010616_cleanup_build_stage_migration.rb b/db/migrate/20180420010616_cleanup_build_stage_migration.rb
new file mode 100644
index 00000000000..0342695ec3d
--- /dev/null
+++ b/db/migrate/20180420010616_cleanup_build_stage_migration.rb
@@ -0,0 +1,28 @@
+class CleanupBuildStageMigration < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ class Build < ActiveRecord::Base
+ include EachBatch
+
+ self.table_name = 'ci_builds'
+ self.inheritance_column = :_type_disabled
+ end
+
+ def up
+ Gitlab::BackgroundMigration.steal('MigrateBuildStage')
+
+ Build.where('stage_id IS NULL').each_batch(of: 50) do |batch|
+ range = batch.pluck('MIN(id)', 'MAX(id)').first
+
+ Gitlab::BackgroundMigration::MigrateBuildStage.new.perform(*range)
+ end
+ end
+
+ def down
+ # noop
+ end
+end
diff --git a/db/migrate/20180503131624_create_remote_mirrors.rb b/db/migrate/20180503131624_create_remote_mirrors.rb
new file mode 100644
index 00000000000..7800186455f
--- /dev/null
+++ b/db/migrate/20180503131624_create_remote_mirrors.rb
@@ -0,0 +1,33 @@
+class CreateRemoteMirrors < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ return if table_exists?(:remote_mirrors)
+
+ create_table :remote_mirrors do |t|
+ t.references :project, index: true, foreign_key: { on_delete: :cascade }
+ t.string :url
+ t.boolean :enabled, default: true
+ t.string :update_status
+ t.datetime :last_update_at
+ t.datetime :last_successful_update_at
+ t.datetime :last_update_started_at
+ t.string :last_error
+ t.boolean :only_protected_branches, default: false, null: false
+ t.string :remote_name
+ t.text :encrypted_credentials
+ t.string :encrypted_credentials_iv
+ t.string :encrypted_credentials_salt
+
+ t.timestamps null: false
+ end
+ end
+
+ def down
+ drop_table(:remote_mirrors) if table_exists?(:remote_mirrors)
+ end
+end
diff --git a/db/migrate/20180503141722_add_remote_mirror_available_overridden_to_projects.rb b/db/migrate/20180503141722_add_remote_mirror_available_overridden_to_projects.rb
new file mode 100644
index 00000000000..841393971f4
--- /dev/null
+++ b/db/migrate/20180503141722_add_remote_mirror_available_overridden_to_projects.rb
@@ -0,0 +1,15 @@
+class AddRemoteMirrorAvailableOverriddenToProjects < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column(:projects, :remote_mirror_available_overridden, :boolean) unless column_exists?(:projects, :remote_mirror_available_overridden)
+ end
+
+ def down
+ remove_column(:projects, :remote_mirror_available_overridden) if column_exists?(:projects, :remote_mirror_available_overridden)
+ end
+end
diff --git a/db/migrate/20180503193542_add_indexes_to_remote_mirror.rb b/db/migrate/20180503193542_add_indexes_to_remote_mirror.rb
new file mode 100644
index 00000000000..9a9decffdab
--- /dev/null
+++ b/db/migrate/20180503193542_add_indexes_to_remote_mirror.rb
@@ -0,0 +1,15 @@
+class AddIndexesToRemoteMirror < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :remote_mirrors, :last_successful_update_at unless index_exists?(:remote_mirrors, :last_successful_update_at)
+ end
+
+ def down
+ remove_index :remote_mirrors, :last_successful_update_at if index_exists? :remote_mirrors, :last_successful_update_at
+ end
+end
diff --git a/db/migrate/20180503193953_add_mirror_available_to_application_settings.rb b/db/migrate/20180503193953_add_mirror_available_to_application_settings.rb
new file mode 100644
index 00000000000..25b9905b1a9
--- /dev/null
+++ b/db/migrate/20180503193953_add_mirror_available_to_application_settings.rb
@@ -0,0 +1,15 @@
+class AddMirrorAvailableToApplicationSettings < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_column_with_default(:application_settings, :mirror_available, :boolean, default: true, allow_null: false) unless column_exists?(:application_settings, :mirror_available)
+ end
+
+ def down
+ remove_column(:application_settings, :mirror_available) if column_exists?(:application_settings, :mirror_available)
+ end
+end
diff --git a/db/migrate/20180503200320_enable_prometheus_metrics_by_default.rb b/db/migrate/20180503200320_enable_prometheus_metrics_by_default.rb
new file mode 100644
index 00000000000..2c8f86ff0f4
--- /dev/null
+++ b/db/migrate/20180503200320_enable_prometheus_metrics_by_default.rb
@@ -0,0 +1,11 @@
+class EnablePrometheusMetricsByDefault < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def up
+ change_column_default :application_settings, :prometheus_metrics_enabled, true
+ end
+
+ def down
+ change_column_default :application_settings, :prometheus_metrics_enabled, false
+ end
+end
diff --git a/db/migrate/limits_ci_build_trace_chunks_raw_data_for_mysql.rb b/db/migrate/limits_ci_build_trace_chunks_raw_data_for_mysql.rb
new file mode 100644
index 00000000000..e1771912c3c
--- /dev/null
+++ b/db/migrate/limits_ci_build_trace_chunks_raw_data_for_mysql.rb
@@ -0,0 +1,9 @@
+class LimitsCiBuildTraceChunksRawDataForMysql < ActiveRecord::Migration
+ def up
+ return unless Gitlab::Database.mysql?
+
+ # Mysql needs MEDIUMTEXT type (up to 16MB) rather than TEXT (up to 64KB)
+ # Because 'raw_data' is always capped by Ci::BuildTraceChunk::CHUNK_SIZE, which is 128KB
+ change_column :ci_build_trace_chunks, :raw_data, :binary, limit: 16.megabytes - 1 #MEDIUMTEXT
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 27c70c03612..65e9cc4ea08 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20180503175054) do
+ActiveRecord::Schema.define(version: 20180503200320) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -134,7 +134,7 @@ ActiveRecord::Schema.define(version: 20180503175054) do
t.integer "cached_markdown_version"
t.boolean "clientside_sentry_enabled", default: false, null: false
t.string "clientside_sentry_dsn"
- t.boolean "prometheus_metrics_enabled", default: false, null: false
+ t.boolean "prometheus_metrics_enabled", default: true, null: false
t.boolean "help_page_hide_commercial_content", default: false
t.string "help_page_support_url"
t.integer "performance_bar_allowed_group_id"
@@ -165,6 +165,7 @@ ActiveRecord::Schema.define(version: 20180503175054) do
t.boolean "pages_domain_verification_enabled", default: true, null: false
t.boolean "allow_local_requests_from_hooks_and_services", default: false, null: false
t.boolean "enforce_terms", default: false
+ t.boolean "mirror_available", default: true, null: false
end
create_table "audit_events", force: :cascade do |t|
@@ -253,6 +254,15 @@ ActiveRecord::Schema.define(version: 20180503175054) do
add_index "chat_teams", ["namespace_id"], name: "index_chat_teams_on_namespace_id", unique: true, using: :btree
+ create_table "ci_build_trace_chunks", id: :bigserial, force: :cascade do |t|
+ t.integer "build_id", null: false
+ t.integer "chunk_index", null: false
+ t.integer "data_store", null: false
+ t.binary "raw_data"
+ end
+
+ add_index "ci_build_trace_chunks", ["build_id", "chunk_index"], name: "index_ci_build_trace_chunks_on_build_id_and_chunk_index", unique: true, using: :btree
+
create_table "ci_build_trace_section_names", force: :cascade do |t|
t.integer "project_id", null: false
t.string "name", null: false
@@ -1593,6 +1603,7 @@ ActiveRecord::Schema.define(version: 20180503175054) do
t.boolean "merge_requests_rebase_enabled", default: false, null: false
t.integer "jobs_cache_index"
t.boolean "pages_https_only", default: true
+ t.boolean "remote_mirror_available_overridden"
end
add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree
@@ -1698,6 +1709,27 @@ ActiveRecord::Schema.define(version: 20180503175054) do
add_index "releases", ["project_id", "tag"], name: "index_releases_on_project_id_and_tag", using: :btree
add_index "releases", ["project_id"], name: "index_releases_on_project_id", using: :btree
+ create_table "remote_mirrors", force: :cascade do |t|
+ t.integer "project_id"
+ t.string "url"
+ t.boolean "enabled", default: true
+ t.string "update_status"
+ t.datetime "last_update_at"
+ t.datetime "last_successful_update_at"
+ t.datetime "last_update_started_at"
+ t.string "last_error"
+ t.boolean "only_protected_branches", default: false, null: false
+ t.string "remote_name"
+ t.text "encrypted_credentials"
+ t.string "encrypted_credentials_iv"
+ t.string "encrypted_credentials_salt"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ add_index "remote_mirrors", ["last_successful_update_at"], name: "index_remote_mirrors_on_last_successful_update_at", using: :btree
+ add_index "remote_mirrors", ["project_id"], name: "index_remote_mirrors_on_project_id", using: :btree
+
create_table "routes", force: :cascade do |t|
t.integer "source_id", null: false
t.string "source_type", null: false
@@ -2110,6 +2142,7 @@ ActiveRecord::Schema.define(version: 20180503175054) do
add_foreign_key "boards", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
+ add_foreign_key "ci_build_trace_chunks", "ci_builds", column: "build_id", on_delete: :cascade
add_foreign_key "ci_build_trace_section_names", "projects", on_delete: :cascade
add_foreign_key "ci_build_trace_sections", "ci_build_trace_section_names", column: "section_name_id", name: "fk_264e112c66", on_delete: :cascade
add_foreign_key "ci_build_trace_sections", "ci_builds", column: "build_id", name: "fk_4ebe41f502", on_delete: :cascade
@@ -2233,6 +2266,7 @@ ActiveRecord::Schema.define(version: 20180503175054) do
add_foreign_key "protected_tags", "projects", name: "fk_8e4af87648", on_delete: :cascade
add_foreign_key "push_event_payloads", "events", name: "fk_36c74129da", on_delete: :cascade
add_foreign_key "releases", "projects", name: "fk_47fe2a0596", on_delete: :cascade
+ add_foreign_key "remote_mirrors", "projects", on_delete: :cascade
add_foreign_key "services", "projects", name: "fk_71cce407f9", on_delete: :cascade
add_foreign_key "snippets", "projects", name: "fk_be41fd4bb7", on_delete: :cascade
add_foreign_key "subscriptions", "projects", on_delete: :cascade
diff --git a/doc/administration/job_traces.md b/doc/administration/job_traces.md
index 84a1ffeec98..f0b2054a7f3 100644
--- a/doc/administration/job_traces.md
+++ b/doc/administration/job_traces.md
@@ -40,3 +40,98 @@ To change the location where the job logs will be stored, follow the steps below
[reconfigure gitlab]: restart_gitlab.md#omnibus-gitlab-reconfigure "How to reconfigure Omnibus GitLab"
[restart gitlab]: restart_gitlab.md#installations-from-source "How to restart GitLab"
+
+## New live trace architecture
+
+> [Introduced][ce-18169] in GitLab 10.4.
+
+> **Notes**:
+- This feature is still Beta, which could impact GitLab.com/on-premises instances, and in the worst case scenario, traces will be lost.
+- This feature is still being discussed in [an issue](https://gitlab.com/gitlab-org/gitlab-ce/issues/46097) for the performance improvements.
+- This feature is off by default. Please check below how to enable/disable this featrue.
+
+**What is "live trace"?**
+
+Job trace that is sent by runner while jobs are running. You can see live trace in job pages UI.
+The live traces are archived once job finishes.
+
+**What is new architecture?**
+
+So far, when GitLab Runner sends a job trace to GitLab-Rails, traces have been saved to file storage as text files.
+This was a problem for [Cloud Native-compatible GitLab application](https://gitlab.com/gitlab-com/migration/issues/23) where GitLab had to rely on File Storage.
+
+This new live trace architecture stores chunks of traces in Redis and database instead of file storage.
+Redis is used as first-class storage, and it stores up-to 128kB. Once the full chunk is sent it will be flushed to database. Afterwhile, the data in Redis and database will be archived to ObjectStorage.
+
+Here is the detailed data flow.
+
+1. GitLab Runner picks a job from GitLab-Rails
+1. GitLab Runner sends a piece of trace to GitLab-Rails
+1. GitLab-Rails appends the data to Redis
+1. If the data in Redis is fulfilled 128kB, the data is flushed to Database.
+1. 2.~4. is continued until the job is finished
+1. Once the job is finished, GitLab-Rails schedules a sidekiq worker to archive the trace
+1. The sidekiq worker archives the trace to Object Storage, and cleanup the trace in Redis and Database
+
+**How to check if it's on or off?**
+
+```ruby
+Feature.enabled?('ci_enable_live_trace')
+```
+
+**How to enable?**
+
+```ruby
+Feature.enable('ci_enable_live_trace')
+```
+
+>**Note:**
+The transition period will be handled gracefully. Upcoming traces will be generated with the new architecture, and on-going live traces will stay with the legacy architecture (i.e. on-going live traces won't be re-generated forcibly with the new architecture).
+
+**How to disable?**
+
+```ruby
+Feature.disable('ci_enable_live_trace')
+```
+
+>**Note:**
+The transition period will be handled gracefully. Upcoming traces will be generated with the legacy architecture, and on-going live traces will stay with the new architecture (i.e. on-going live traces won't be re-generated forcibly with the legacy architecture).
+
+**Redis namespace:**
+
+`Gitlab::Redis::SharedState`
+
+**Potential impact:**
+
+- This feature could incur data loss:
+ - Case 1: When all data in Redis are accidentally flushed.
+ - On-going live traces could be recovered by re-sending traces (This is supported by all versions of GitLab Runner)
+ - Finished jobs which has not archived live traces will lose the last part (~128kB) of trace data.
+ - Case 2: When sidekiq workers failed to archive (e.g. There was a bug that prevents archiving process, Sidekiq inconsistancy, etc):
+ - Currently all trace data in Redis will be deleted after one week. If the sidekiq workers can't finish by the expiry date, the part of trace data will be lost.
+- This feature could consume all memory on Redis instance. If the number of jobs is 1000, 128MB (128kB * 1000) is consumed.
+- This feature could pressure Database replication lag. `INSERT` are generated to indicate that we have trace chunk. `UPDATE` with 128kB of data is issued once we receive multiple chunks.
+- and so on
+
+**How to test?**
+
+We're currently evaluating this feature on dev.gitalb.org or staging.gitlab.com to verify this features. Here is the list of tests/measurements.
+
+- Features:
+ - Live traces should be visible on job pages
+ - Archived traces should be visible on job pages
+ - Live traces should be archived to Object storage
+ - Live traces should be cleaned up after archived
+ - etc
+- Performance:
+ - Schedule 1000~10000 jobs and let GitLab-runners process concurrently. Measure memoery presssure, IO load, etc.
+ - etc
+- Failover:
+ - Simulate Redis outage
+ - etc
+
+**How to verify the correctnesss?**
+
+- TBD
+
+[ce-44935]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/18169
diff --git a/doc/administration/repository_checks.md b/doc/administration/repository_checks.md
index ee37ea49874..efeec9db517 100644
--- a/doc/administration/repository_checks.md
+++ b/doc/administration/repository_checks.md
@@ -13,12 +13,12 @@ checks failed you can see their output on the admin log page under
## Periodic checks
-When enabled, GitLab periodically runs a repository check on all project
-repositories and wiki repositories in order to detect data corruption problems.
+When enabled, GitLab periodically runs a repository check on all project
+repositories and wiki repositories in order to detect data corruption.
A project will be checked no more than once per month. If any projects
fail their repository checks all GitLab administrators will receive an email
-notification of the situation. This notification is sent out once a week on
-Sunday, by default.
+notification of the situation. This notification is sent out once a week,
+by default, midnight at the start of Sunday.
## Disabling periodic checks
@@ -28,16 +28,18 @@ panel.
## What to do if a check failed
If the repository check fails for some repository you should look up the error
-in repocheck.log (in the admin panel or on disk; see
-`/var/log/gitlab/gitlab-rails` for Omnibus installations or
-`/home/git/gitlab/log` for installations from source). Once you have
-resolved the issue use the admin panel to trigger a new repository check on
-the project. This will clear the 'check failed' state.
+in `repocheck.log`:
+
+- in the [admin panel](logs.md#repocheck.log)
+- or on disk, see:
+ - `/var/log/gitlab/gitlab-rails` for Omnibus installations
+ - `/home/git/gitlab/log` for installations from source
If for some reason the periodic repository check caused a lot of false
-alarms you can choose to clear ALL repository check states from the
-'Settings' page of the admin panel.
+alarms you can choose to clear *all* repository check states by
+clicking "Clear all repository checks" on the **Settings** page of the
+admin panel (`/admin/application_settings`).
---
[ce-3232]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3232 "Auto git fsck"
-[git-fsck]: https://www.kernel.org/pub/software/scm/git/docs/git-fsck.html "git fsck documentation"
+[git-fsck]: https://git-scm.com/docs/git-fsck "git fsck documentation"
diff --git a/doc/development/fe_guide/index.md b/doc/development/fe_guide/index.md
index 3b4dfd50761..5a67414e835 100644
--- a/doc/development/fe_guide/index.md
+++ b/doc/development/fe_guide/index.md
@@ -45,6 +45,9 @@ Common JavaScript design patterns in GitLab's codebase.
## [Vue.js Best Practices](vue.md)
Vue specific design patterns and practices.
+## [Vuex](vuex.md)
+Vuex specific design patterns and practices.
+
## [Axios](axios.md)
Axios specific practices and gotchas.
diff --git a/doc/development/fe_guide/vue.md b/doc/development/fe_guide/vue.md
index 9c4b0e86351..f971d8b7388 100644
--- a/doc/development/fe_guide/vue.md
+++ b/doc/development/fe_guide/vue.md
@@ -1,29 +1,7 @@
# Vue
-For more complex frontend features, we recommend using Vue.js. It shares
-some ideas with React.js as well as Angular.
-
To get started with Vue, read through [their documentation][vue-docs].
-## When to use Vue.js
-
-We recommend using Vue for more complex features. Here are some guidelines for when to use Vue.js:
-
-- If you are starting a new feature or refactoring an old one that highly interacts with the DOM;
-- For real time data updates;
-- If you are creating a component that will be reused elsewhere;
-
-## When not to use Vue.js
-
-We don't want to refactor all GitLab frontend code into Vue.js, here are some guidelines for
-when not to use Vue.js:
-
-- Adding or changing static information;
-- Features that highly depend on jQuery will be hard to work with Vue.js;
-- Features without reactive data;
-
-As always, the Frontend Architectural Experts are available to help with any Vue or JavaScript questions.
-
## Vue architecture
All new features built with Vue.js must follow a [Flux architecture][flux].
@@ -57,15 +35,15 @@ new_feature
│ └── ...
├── stores
│ └── new_feature_store.js
-├── services
+├── services # only when not using vuex
│ └── new_feature_service.js
-├── new_feature_bundle.js
+├── index.js
```
_For consistency purposes, we recommend you to follow the same structure._
Let's look into each of them:
-### A `*_bundle.js` file
+### A `index.js` file
This is the index file of your new feature. This is where the root Vue instance
of the new feature should be.
@@ -144,30 +122,30 @@ in one table would not be a good use of this pattern.
You can read more about components in Vue.js site, [Component System][component-system]
#### Components Gotchas
-1. Using SVGs in components: To use an SVG in a template we need to make it a property we can access through the component.
-A `prop` and a property returned by the `data` functions require `vue` to set a `getter` and a `setter` for each of them.
-The SVG should be a computed property in order to improve performance, note that computed properties are cached based on their dependencies.
-
-```javascript
-// bad
-import svg from 'svg.svg';
-data() {
- return {
- myIcon: svg,
- };
-};
-
-// good
-import svg from 'svg.svg';
-computed: {
- myIcon() {
- return svg;
- }
-}
-```
+1. Using SVGs icons in components: To use an SVG icon in a template use the `icon.vue`
+1. Using SVGs illustrations in components: To use an SVG illustrations in a template provide the path as a prop and display it through a standard img tag.
+ ```javascript
+ <script>
+ export default {
+ props: {
+ svgIllustrationPath: {
+ type: String,
+ required: true,
+ },
+ },
+ };
+ <script>
+ <template>
+ <img :src="svgIllustrationPath" />
+ </template>
+ ```
### A folder for the Store
+#### Vuex
+Check this [page](vuex.md) for more details.
+
+#### Flux like state management
The Store is a class that allows us to manage the state in a single
source of truth. It is not aware of the service or the components.
@@ -176,6 +154,8 @@ itself, please read this guide: [State Management][state-management]
### A folder for the Service
+**If you are using Vuex you won't need this step**
+
The Service is a class used only to communicate with the server.
It does not store or manipulate any data. It is not aware of the store or the components.
We use [axios][axios] to communicate with the server.
@@ -273,6 +253,9 @@ import Store from 'store';
import Service from 'service';
import TodoComponent from 'todoComponent';
export default {
+ components: {
+ todo: TodoComponent,
+ },
/**
* Although most data belongs in the store, each component it's own state.
* We want to show a loading spinner while we are fetching the todos, this state belong
@@ -291,10 +274,6 @@ export default {
};
},
- components: {
- todo: TodoComponent,
- },
-
created() {
this.service = new Service('todos');
@@ -476,201 +455,6 @@ need to test the rendered output. [Vue][vue-test] guide's to unit test show us e
Refer to [mock axios](axios.md#mock-axios-response-on-tests)
-## Vuex
-To manage the state of an application you may use [Vuex][vuex-docs].
-
-_Note:_ All of the below is explained in more detail in the official [Vuex documentation][vuex-docs].
-
-### Separation of concerns
-Vuex is composed of State, Getters, Mutations, Actions and Modules.
-
-When a user clicks on an action, we need to `dispatch` it. This action will `commit` a mutation that will change the state.
-_Note:_ The action itself will not update the state, only a mutation should update the state.
-
-#### File structure
-When using Vuex at GitLab, separate this concerns into different files to improve readability. If you can, separate the Mutation Types as well:
-
-```
-└── store
- ├── index.js # where we assemble modules and export the store
- ├── actions.js # actions
- ├── mutations.js # mutations
- ├── getters.js # getters
- └── mutation_types.js # mutation types
-```
-The following examples show an application that lists and adds users to the state.
-
-##### `index.js`
-This is the entry point for our store. You can use the following as a guide:
-
-```javascript
-import Vue from 'vue';
-import Vuex from 'vuex';
-import * as actions from './actions';
-import * as getters from './getters';
-import mutations from './mutations';
-
-Vue.use(Vuex);
-
-export default new Vuex.Store({
- actions,
- getters,
- mutations,
- state: {
- users: [],
- },
-});
-```
-_Note:_ If the state of the application is too complex, an individual file for the state may be better.
-
-##### `actions.js`
-An action commits a mutation. In this file, we will write the actions that will commit the respective mutation:
-
-```javascript
- import * as types from './mutation_types';
-
- export const addUser = ({ commit }, user) => {
- commit(types.ADD_USER, user);
- };
-```
-
-To dispatch an action from a component, use the `mapActions` helper:
-```javascript
-import { mapActions } from 'vuex';
-
-{
- methods: {
- ...mapActions([
- 'addUser',
- ]),
- onClickUser(user) {
- this.addUser(user);
- },
- },
-};
-```
-
-##### `getters.js`
-Sometimes we may need to get derived state based on store state, like filtering for a specific prop. This can be done through the `getters`:
-
-```javascript
-// get all the users with pets
-export getUsersWithPets = (state, getters) => {
- return state.users.filter(user => user.pet !== undefined);
-};
-```
-
-To access a getter from a component, use the `mapGetters` helper:
-```javascript
-import { mapGetters } from 'vuex';
-
-{
- computed: {
- ...mapGetters([
- 'getUsersWithPets',
- ]),
- },
-};
-```
-
-##### `mutations.js`
-The only way to actually change state in a Vuex store is by committing a mutation.
-
-```javascript
- import * as types from './mutation_types';
-
- export default {
- [types.ADD_USER](state, user) {
- state.users.push(user);
- },
- };
-```
-
-##### `mutations_types.js`
-From [vuex mutations docs][vuex-mutations]:
-> It is a commonly seen pattern to use constants for mutation types in various Flux implementations. This allows the code to take advantage of tooling like linters, and putting all constants in a single file allows your collaborators to get an at-a-glance view of what mutations are possible in the entire application.
-
-```javascript
-export const ADD_USER = 'ADD_USER';
-```
-
-### How to include the store in your application
-The store should be included in the main component of your application:
-```javascript
- // app.vue
- import store from 'store'; // it will include the index.js file
-
- export default {
- name: 'application',
- store,
- ...
- };
-```
-
-### Vuex Gotchas
-1. Avoid calling a mutation directly. Always use an action to commit a mutation. Doing so will keep consistency through out the application. From Vuex docs:
-
- > why don't we just call store.commit('action') directly? Well, remember that mutations must be synchronous? Actions aren't. We can perform asynchronous operations inside an action.
-
- ```javascript
- // component.vue
-
- // bad
- created() {
- this.$store.commit('mutation');
- }
-
- // good
- created() {
- this.$store.dispatch('action');
- }
- ```
-1. When possible, use mutation types instead of hardcoding strings. It will be less error prone.
-1. The State will be accessible in all components descending from the use where the store is instantiated.
-
-### Testing Vuex
-#### Testing Vuex concerns
-Refer to [vuex docs][vuex-testing] regarding testing Actions, Getters and Mutations.
-
-#### Testing components that need a store
-Smaller components might use `store` properties to access the data.
-In order to write unit tests for those components, we need to include the store and provide the correct state:
-
-```javascript
-//component_spec.js
-import Vue from 'vue';
-import store from './store';
-import component from './component.vue'
-
-describe('component', () => {
- let vm;
- let Component;
-
- beforeEach(() => {
- Component = Vue.extend(issueActions);
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('should show a user', () => {
- const user = {
- name: 'Foo',
- age: '30',
- };
-
- // populate the store
- store.dispatch('addUser', user);
-
- vm = new Component({
- store,
- propsData: props,
- }).$mount();
- });
-});
-```
-
[vue-docs]: http://vuejs.org/guide/index.html
[issue-boards]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/app/assets/javascripts/boards
[environments-table]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/app/assets/javascripts/environments
@@ -681,9 +465,5 @@ describe('component', () => {
[vue-test]: https://vuejs.org/v2/guide/unit-testing.html
[issue-boards-service]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/assets/javascripts/boards/services/board_service.js.es6
[flux]: https://facebook.github.io/flux
-[vuex-docs]: https://vuex.vuejs.org
-[vuex-structure]: https://vuex.vuejs.org/en/structure.html
-[vuex-mutations]: https://vuex.vuejs.org/en/mutations.html
-[vuex-testing]: https://vuex.vuejs.org/en/testing.html
[axios]: https://github.com/axios/axios
[axios-interceptors]: https://github.com/axios/axios#interceptors
diff --git a/doc/development/fe_guide/vuex.md b/doc/development/fe_guide/vuex.md
new file mode 100644
index 00000000000..6a89bfc7721
--- /dev/null
+++ b/doc/development/fe_guide/vuex.md
@@ -0,0 +1,358 @@
+# Vuex
+To manage the state of an application you should use [Vuex][vuex-docs].
+
+_Note:_ All of the below is explained in more detail in the official [Vuex documentation][vuex-docs].
+
+## Separation of concerns
+Vuex is composed of State, Getters, Mutations, Actions and Modules.
+
+When a user clicks on an action, we need to `dispatch` it. This action will `commit` a mutation that will change the state.
+_Note:_ The action itself will not update the state, only a mutation should update the state.
+
+## File structure
+When using Vuex at GitLab, separate this concerns into different files to improve readability:
+
+```
+└── store
+ ├── index.js # where we assemble modules and export the store
+ ├── actions.js # actions
+ ├── mutations.js # mutations
+ ├── getters.js # getters
+ ├── state.js # state
+ └── mutation_types.js # mutation types
+```
+The following example shows an application that lists and adds users to the state.
+(For a more complex example implementation take a look at the security applications store in [here](https://gitlab.com/gitlab-org/gitlab-ee/tree/master/ee/app/assets/javascripts/vue_shared/security_reports/store))
+
+### `index.js`
+This is the entry point for our store. You can use the following as a guide:
+
+```javascript
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import state from './state';
+
+Vue.use(Vuex);
+
+export default new Vuex.Store({
+ actions,
+ getters,
+ mutations,
+ state,
+});
+```
+
+### `state.js`
+The first thing you should do before writing any code is to design the state.
+
+Often we need to provide data from haml to our Vue application. Let's store it in the state for better access.
+
+```javascript
+ export default {
+ endpoint: null,
+
+ isLoading: false,
+ error: null,
+
+ isAddingUser: false,
+ errorAddingUser: false,
+
+ users: [],
+ };
+```
+
+#### Access `state` properties
+You can use `mapState` to access state properties in the components.
+
+### `actions.js`
+An action is a playload of information to send data from our application to our store.
+
+An action is usually composed by a `type` and a `payload` and they describe what happened.
+Enforcing that every change is described as an action lets us have a clear understanting of what is going on in the app.
+
+In this file, we will write the actions that will call the respective mutations:
+
+```javascript
+ import * as types from './mutation_types';
+ import axios from '~/lib/utils/axios-utils';
+ import createFlash from '~/flash';
+
+ export const requestUsers = ({ commit }) => commit(types.REQUEST_USERS);
+ export const receiveUsersSuccess = ({ commit }, data) => commit(types.RECEIVE_USERS_SUCCESS, data);
+ export const receiveUsersError = ({ commit }, error) => commit(types.REQUEST_USERS_ERROR, error);
+
+ export const fetchUsers = ({ state, dispatch }) => {
+ dispatch('requestUsers');
+
+ axios.get(state.endoint)
+ .then(({ data }) => dispatch('receiveUsersSuccess', data))
+ .catch((error) => {
+ dispatch('receiveUsersError', error)
+ createFlash('There was an error')
+ });
+ }
+
+ export const requestAddUser = ({ commit }) => commit(types.REQUEST_ADD_USER);
+ export const receiveAddUserSuccess = ({ commit }, data) => commit(types.RECEIVE_ADD_USER_SUCCESS, data);
+ export const receiveAddUserError = ({ commit }, error) => commit(types.REQUEST_ADD_USER_ERROR, error);
+
+ export const addUser = ({ state, dispatch }, user) => {
+ dispatch('requestAddUser');
+
+ axios.post(state.endoint, user)
+ .then(({ data }) => dispatch('receiveAddUserSuccess', data))
+ .catch((error) => dispatch('receiveAddUserError', error));
+ }
+```
+
+#### Actions Pattern: `request` and `receive` namespaces
+When a request is made we often want to show a loading state to the user.
+
+Instead of creating an action to toggle the loading state and dispatch it in the component,
+create:
+1. An action `requestSomething`, to toggle the loading state
+1. An action `receiveSomethingSuccess`, to handle the success callback
+1. An action `receiveSomethingError`, to handle the error callback
+1. An action `fetchSomething` to make the request.
+ 1. In case your application does more than a `GET` request you can use these as examples:
+ 1. `PUT`: `createSomething`
+ 2. `POST`: `updateSomething`
+ 3. `DELETE`: `deleteSomething`
+
+The component MUST only dispatch the `fetchNamespace` action. Actions namespaced with `request` or `receive` should not be called from the component
+The `fetch` action will be responsible to dispatch `requestNamespace`, `receiveNamespaceSuccess` and `receiveNamespaceError`
+
+By following this pattern we guarantee:
+1. All aplications follow the same pattern, making it easier for anyone to maintain the code
+1. All data in the application follows the same lifecycle pattern
+1. Actions are contained and human friendly
+1. Unit tests are easier
+1. Actions are simple and straightforward
+
+#### Dispatching actions
+To dispatch an action from a component, use the `mapActions` helper:
+```javascript
+import { mapActions } from 'vuex';
+
+{
+ methods: {
+ ...mapActions([
+ 'addUser',
+ ]),
+ onClickUser(user) {
+ this.addUser(user);
+ },
+ },
+};
+```
+
+#### `mutations.js`
+The mutations specify how the application state changes in response to actions sent to the store.
+The only way to change state in a Vuex store should be by committing a mutation.
+
+**It's a good idea to think of the state before writing any code.**
+
+Remember that actions only describe that something happened, they don't describe how the application state changes.
+
+**Never commit a mutation directly from a component**
+
+```javascript
+ import * as types from './mutation_types';
+
+ export default {
+ [types.REQUEST_USERS](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_USERS_SUCCESS](state, data) {
+ // Do any needed data transformation to the received payload here
+ state.users = data;
+ state.isLoading = false;
+ },
+ [types.REQUEST_USERS_ERROR](state, error) {
+ state.isLoading = false;
+ },
+ [types.REQUEST_ADD_USER](state, user) {
+ state.isAddingUser = true;
+ },
+ [types.RECEIVE_ADD_USER_SUCCESS](state, user) {
+ state.isAddingUser = false;
+ state.users.push(user);
+ },
+ [types.REQUEST_ADD_USER_ERROR](state, error) {
+ state.isAddingUser = true;
+ state.errorAddingUser = error∂;
+ },
+ };
+```
+
+#### `getters.js`
+Sometimes we may need to get derived state based on store state, like filtering for a specific prop.
+Using a getter will also cache the result based on dependencies due to [how computed props work](https://vuejs.org/v2/guide/computed.html#Computed-Caching-vs-Methods)
+This can be done through the `getters`:
+
+```javascript
+// get all the users with pets
+export const getUsersWithPets = (state, getters) => {
+ return state.users.filter(user => user.pet !== undefined);
+};
+```
+
+To access a getter from a component, use the `mapGetters` helper:
+```javascript
+import { mapGetters } from 'vuex';
+
+{
+ computed: {
+ ...mapGetters([
+ 'getUsersWithPets',
+ ]),
+ },
+};
+```
+
+#### `mutations_types.js`
+From [vuex mutations docs][vuex-mutations]:
+> It is a commonly seen pattern to use constants for mutation types in various Flux implementations. This allows the code to take advantage of tooling like linters, and putting all constants in a single file allows your collaborators to get an at-a-glance view of what mutations are possible in the entire application.
+
+```javascript
+export const ADD_USER = 'ADD_USER';
+```
+
+### How to include the store in your application
+The store should be included in the main component of your application:
+```javascript
+ // app.vue
+ import store from 'store'; // it will include the index.js file
+
+ export default {
+ name: 'application',
+ store,
+ ...
+ };
+```
+
+### Communicating with the Store
+```javascript
+<script>
+import { mapActions, mapState, mapGetters } from 'vuex';
+import store from './store';
+
+export default {
+ store,
+ computed: {
+ ...mapGetters([
+ 'getUsersWithPets'
+ ]),
+ ...mapState([
+ 'isLoading',
+ 'users',
+ 'error',
+ ]),
+ },
+ methods: {
+ ...mapActions([
+ 'fetchUsers',
+ 'addUser',
+ ]),
+
+ onClickAddUser(data) {
+ this.addUser(data);
+ }
+ },
+
+ created() {
+ this.fetchUsers()
+ }
+}
+</script>
+<template>
+ <ul>
+ <li v-if="isLoading">
+ Loading...
+ </li>
+ <li v-else-if="error">
+ {{ error }}
+ </li>
+ <template v-else>
+ <li
+ v-for="user in users"
+ :key="user.id"
+ >
+ {{ user }}
+ </li>
+ </template>
+ </ul>
+</template>
+```
+
+### Vuex Gotchas
+1. Do not call a mutation directly. Always use an action to commit a mutation. Doing so will keep consistency through out the application. From Vuex docs:
+
+ > why don't we just call store.commit('action') directly? Well, remember that mutations must be synchronous? Actions aren't. We can perform asynchronous operations inside an action.
+
+ ```javascript
+ // component.vue
+
+ // bad
+ created() {
+ this.$store.commit('mutation');
+ }
+
+ // good
+ created() {
+ this.$store.dispatch('action');
+ }
+ ```
+1. Use mutation types instead of hardcoding strings. It will be less error prone.
+1. The State will be accessible in all components descending from the use where the store is instantiated.
+
+### Testing Vuex
+#### Testing Vuex concerns
+Refer to [vuex docs][vuex-testing] regarding testing Actions, Getters and Mutations.
+
+#### Testing components that need a store
+Smaller components might use `store` properties to access the data.
+In order to write unit tests for those components, we need to include the store and provide the correct state:
+
+```javascript
+//component_spec.js
+import Vue from 'vue';
+import store from './store';
+import component from './component.vue'
+
+describe('component', () => {
+ let vm;
+ let Component;
+
+ beforeEach(() => {
+ Component = Vue.extend(issueActions);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ it('should show a user', () => {
+ const user = {
+ name: 'Foo',
+ age: '30',
+ };
+
+ // populate the store
+ store.dipatch('addUser', user);
+
+ vm = new Component({
+ store,
+ propsData: props,
+ }).$mount();
+ });
+});
+```
+
+[vuex-docs]: https://vuex.vuejs.org
+[vuex-structure]: https://vuex.vuejs.org/en/structure.html
+[vuex-mutations]: https://vuex.vuejs.org/en/mutations.html
+[vuex-testing]: https://vuex.vuejs.org/en/testing.html
diff --git a/doc/install/installation.md b/doc/install/installation.md
index fa5bcfa6f07..a0ae9017f71 100644
--- a/doc/install/installation.md
+++ b/doc/install/installation.md
@@ -301,9 +301,9 @@ sudo usermod -aG redis git
### Clone the Source
# Clone GitLab repository
- sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 10-7-stable gitlab
+ sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 10-8-stable gitlab
-**Note:** You can change `10-6-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server!
+**Note:** You can change `10-8-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server!
### Configure It
diff --git a/doc/user/project/integrations/prometheus_library/nginx.md b/doc/user/project/integrations/prometheus_library/nginx.md
index fea3231006b..557487e1a75 100644
--- a/doc/user/project/integrations/prometheus_library/nginx.md
+++ b/doc/user/project/integrations/prometheus_library/nginx.md
@@ -10,17 +10,19 @@ The [Prometheus service](../prometheus.md) must be enabled.
## Metrics supported
+NGINX server metrics are detected, which tracks the pages and content directly served by NGINX.
+
| Name | Query |
| ---- | ----- |
-| Throughput (req/sec) | sum(rate(nginx_responses_total{server_zone!="*", server_zone!="_", %{environment_filter}}[2m])) by (status_code) |
-| Latency (ms) | avg(nginx_upstream_response_msecs_avg{%{environment_filter}}) |
-| HTTP Error Rate (HTTP Errors / sec) | rate(nginx_responses_total{status_code="5xx", %{environment_filter}}[2m])) |
+| Throughput (req/sec) | sum(rate(nginx_server_requests{server_zone!="*", server_zone!="_", %{environment_filter}}[2m])) by (code) |
+| Latency (ms) | avg(nginx_server_requestMsec{%{environment_filter}}) |
+| HTTP Error Rate (HTTP Errors / sec) | sum(rate(nginx_server_requests{code="5xx", %{environment_filter}}[2m])) |
## Configuring Prometheus to monitor for NGINX metrics
To get started with NGINX monitoring, you should first enable the [VTS statistics](https://github.com/vozlt/nginx-module-vts)) module for your NGINX server. This will capture and display statistics in an HTML readable form. Next, you should install and configure the [NGINX VTS exporter](https://github.com/hnlq715/nginx-vts-exporter) which parses these statistics and translates them into a Prometheus monitoring endpoint.
-If you are using NGINX as your Kubernetes ingress, there is [upcoming direct support](https://github.com/kubernetes/ingress/pull/423) for enabling Prometheus monitoring in the 0.9.0 release.
+If you are using NGINX as your Kubernetes ingress, GitLab will [automatically detect](nginx_ingress.md) the metrics once enabled in 0.9.0 and later releases.
## Specifying the Environment label
diff --git a/doc/workflow/repository_mirroring.md b/doc/workflow/repository_mirroring.md
new file mode 100644
index 00000000000..dbe63144e38
--- /dev/null
+++ b/doc/workflow/repository_mirroring.md
@@ -0,0 +1,111 @@
+# Repository mirroring
+
+Repository Mirroring is a way to mirror repositories from external sources.
+It can be used to mirror all branches, tags, and commits that you have
+in your repository.
+
+Your mirror at GitLab will be updated automatically. You can
+also manually trigger an update at most once every 5 minutes.
+
+## Overview
+
+Repository mirroring is very useful when, for some reason, you must use a
+project from another source.
+
+There are two kinds of repository mirroring features supported by GitLab:
+**push** and **pull**, the latter being only available in GitLab Enterprise Edition.
+The **push** method mirrors the repository in GitLab to another location.
+
+Once the mirror repository is updated, all new branches,
+tags, and commits will be visible in the project's activity feed.
+Users with at least [developer access][perms] to the project can also force an
+immediate update with the click of a button. This button will not be available if
+the mirror is already being updated or 5 minutes still haven't passed since its last update.
+
+A few things/limitations to consider:
+
+- The repository must be accessible over `http://`, `https://`, `ssh://` or `git://`.
+- If your HTTP repository is not publicly accessible, add authentication
+ information to the URL, like: `https://username@gitlab.company.com/group/project.git`.
+ In some cases, you might need to use a personal access token instead of a
+ password, e.g., you want to mirror to GitHub and have 2FA enabled.
+- The import will time out after 15 minutes. For repositories that take longer
+ use a clone/push combination.
+- The Git LFS objects will not be synced. You'll need to push/pull them
+ manually.
+
+## Use-case
+
+- You have old projects in another source that you don't use actively anymore,
+ but don't want to remove for archiving purposes. In that case, you can create
+ a push mirror so that your active GitLab repository can push its changes to the
+ old location.
+
+## Pushing to a remote repository **[STARTER]**
+
+>[Introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/249) in
+GitLab Enterprise Edition 8.7. [Moved to GitLab Community Edition][ce-18715] in 10.8.
+
+For an existing project, you can set up push mirror from your project's
+**Settings âž” Repository** and searching for the "Push to a remote repository"
+section. Check the "Remote mirror repository" box and fill in the Git URL of
+the repository to push to. Click **Save changes** for the changes to take
+effect.
+
+![Push settings](repository_mirroring/repository_mirroring_push_settings.png)
+
+When push mirroring is enabled, you are advised not to push commits directly
+to the mirrored repository to prevent the mirror diverging.
+All changes will end up in the mirrored repository whenever commits
+are pushed to GitLab, or when a [forced update](#forcing-an-update) is
+initiated.
+
+Pushes into GitLab are automatically pushed to the remote mirror at least once
+every 5 minutes after they are received or once every minute if **push only
+protected branches** is enabled.
+
+In case of a diverged branch, you will see an error indicated at the **Mirror
+repository** settings.
+
+![Diverged branch](
+repository_mirroring/repository_mirroring_diverged_branch_push.png)
+
+### Push only protected branches
+
+>[Introduced][ee-3350] in GitLab Enterprise Edition 10.3. [Moved to GitLab Community Edition][ce-18715] in 10.8.
+
+You can choose to only push your protected branches from GitLab to your remote repository.
+
+To use this option go to your project's repository settings page under push mirror.
+
+## Setting up a push mirror from GitLab to GitHub
+
+To set up a mirror from GitLab to GitHub, you need to follow these steps:
+
+1. Create a [GitHub personal access token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/) with the "public_repo" box checked:
+
+ ![edit personal access token GitHub](repository_mirroring/repository_mirroring_github_edit_personal_access_token.png)
+
+1. Fill in the "Git repository URL" with the personal access token replacing the password `https://GitHubUsername:GitHubPersonalAccessToken@github.com/group/project.git`:
+
+ ![push to remote repo](repository_mirroring/repository_mirroring_gitlab_push_to_a_remote_repository.png)
+
+1. Save
+1. And either wait or trigger the "Update Now" button:
+
+ ![update now](repository_mirroring/repository_mirroring_gitlab_push_to_a_remote_repository_update_now.png)
+
+## Forcing an update
+
+While mirrors are scheduled to update automatically, you can always force an update
+by using the **Update now** button which is exposed in various places:
+
+- in the commits page
+- in the branches page
+- in the tags page
+- in the **Mirror repository** settings page
+
+[ee-3350]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/3350
+[ce-18715]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/18715
+[perms]: ../user/permissions.md
+
diff --git a/doc/workflow/repository_mirroring/repository_mirroring_diverged_branch_push.png b/doc/workflow/repository_mirroring/repository_mirroring_diverged_branch_push.png
new file mode 100644
index 00000000000..038b05cb31d
--- /dev/null
+++ b/doc/workflow/repository_mirroring/repository_mirroring_diverged_branch_push.png
Binary files differ
diff --git a/doc/workflow/repository_mirroring/repository_mirroring_github_edit_personal_access_token.png b/doc/workflow/repository_mirroring/repository_mirroring_github_edit_personal_access_token.png
new file mode 100644
index 00000000000..139de42d8db
--- /dev/null
+++ b/doc/workflow/repository_mirroring/repository_mirroring_github_edit_personal_access_token.png
Binary files differ
diff --git a/doc/workflow/repository_mirroring/repository_mirroring_gitlab_push_to_a_remote_repository.png b/doc/workflow/repository_mirroring/repository_mirroring_gitlab_push_to_a_remote_repository.png
new file mode 100644
index 00000000000..ccbc1d92329
--- /dev/null
+++ b/doc/workflow/repository_mirroring/repository_mirroring_gitlab_push_to_a_remote_repository.png
Binary files differ
diff --git a/doc/workflow/repository_mirroring/repository_mirroring_gitlab_push_to_a_remote_repository_update_now.png b/doc/workflow/repository_mirroring/repository_mirroring_gitlab_push_to_a_remote_repository_update_now.png
new file mode 100644
index 00000000000..b16b3d2828e
--- /dev/null
+++ b/doc/workflow/repository_mirroring/repository_mirroring_gitlab_push_to_a_remote_repository_update_now.png
Binary files differ
diff --git a/doc/workflow/repository_mirroring/repository_mirroring_push_settings.png b/doc/workflow/repository_mirroring/repository_mirroring_push_settings.png
new file mode 100644
index 00000000000..f8199aa7c0f
--- /dev/null
+++ b/doc/workflow/repository_mirroring/repository_mirroring_push_settings.png
Binary files differ
diff --git a/lib/api/runner.rb b/lib/api/runner.rb
index 67896ae1fc5..cd7d6603171 100644
--- a/lib/api/runner.rb
+++ b/lib/api/runner.rb
@@ -153,9 +153,20 @@ module API
content_range = request.headers['Content-Range']
content_range = content_range.split('-')
- stream_size = job.trace.append(request.body.read, content_range[0].to_i)
- if stream_size < 0
- break error!('416 Range Not Satisfiable', 416, { 'Range' => "0-#{-stream_size}" })
+ # TODO:
+ # it seems that `Content-Range` as formatted by runner is wrong,
+ # the `byte_end` should point to final byte, but it points byte+1
+ # that means that we have to calculate end of body,
+ # as we cannot use `content_length[1]`
+ # Issue: https://gitlab.com/gitlab-org/gitlab-runner/issues/3275
+
+ body_data = request.body.read
+ body_start = content_range[0].to_i
+ body_end = body_start + body_data.bytesize
+
+ stream_size = job.trace.append(body_data, body_start)
+ unless stream_size == body_end
+ break error!('416 Range Not Satisfiable', 416, { 'Range' => "0-#{stream_size}" })
end
status 202
diff --git a/lib/gitlab/ci/trace.rb b/lib/gitlab/ci/trace.rb
index 47b67930c6d..fe15fabc2e8 100644
--- a/lib/gitlab/ci/trace.rb
+++ b/lib/gitlab/ci/trace.rb
@@ -36,16 +36,16 @@ module Gitlab
end
def set(data)
- write do |stream|
+ write('w+b') do |stream|
data = job.hide_secrets(data)
stream.set(data)
end
end
def append(data, offset)
- write do |stream|
+ write('a+b') do |stream|
current_length = stream.size
- break -current_length unless current_length == offset
+ break current_length unless current_length == offset
data = job.hide_secrets(data)
stream.append(data, offset)
@@ -54,13 +54,15 @@ module Gitlab
end
def exist?
- trace_artifact&.exists? || current_path.present? || old_trace.present?
+ trace_artifact&.exists? || job.trace_chunks.any? || current_path.present? || old_trace.present?
end
def read
stream = Gitlab::Ci::Trace::Stream.new do
if trace_artifact
trace_artifact.open
+ elsif job.trace_chunks.any?
+ Gitlab::Ci::Trace::ChunkedIO.new(job)
elsif current_path
File.open(current_path, "rb")
elsif old_trace
@@ -73,9 +75,15 @@ module Gitlab
stream&.close
end
- def write
+ def write(mode)
stream = Gitlab::Ci::Trace::Stream.new do
- File.open(ensure_path, "a+b")
+ if current_path
+ File.open(current_path, mode)
+ elsif Feature.enabled?('ci_enable_live_trace')
+ Gitlab::Ci::Trace::ChunkedIO.new(job)
+ else
+ File.open(ensure_path, mode)
+ end
end
yield(stream).tap do
@@ -92,6 +100,7 @@ module Gitlab
FileUtils.rm(trace_path, force: true)
end
+ job.trace_chunks.fast_destroy_all
job.erase_old_trace!
end
@@ -99,7 +108,12 @@ module Gitlab
raise ArchiveError, 'Already archived' if trace_artifact
raise ArchiveError, 'Job is not finished yet' unless job.complete?
- if current_path
+ if job.trace_chunks.any?
+ Gitlab::Ci::Trace::ChunkedIO.new(job) do |stream|
+ archive_stream!(stream)
+ stream.destroy!
+ end
+ elsif current_path
File.open(current_path) do |stream|
archive_stream!(stream)
FileUtils.rm(current_path)
@@ -116,7 +130,7 @@ module Gitlab
def archive_stream!(stream)
clone_file!(stream, JobArtifactUploader.workhorse_upload_path) do |clone_path|
- create_job_trace!(job, clone_path)
+ create_build_trace!(job, clone_path)
end
end
@@ -132,7 +146,7 @@ module Gitlab
end
end
- def create_job_trace!(job, path)
+ def create_build_trace!(job, path)
File.open(path) do |stream|
job.create_job_artifacts_trace!(
project: job.project,
diff --git a/lib/gitlab/ci/trace/chunked_io.rb b/lib/gitlab/ci/trace/chunked_io.rb
new file mode 100644
index 00000000000..bfe0c2a2c26
--- /dev/null
+++ b/lib/gitlab/ci/trace/chunked_io.rb
@@ -0,0 +1,231 @@
+##
+# This class is compatible with IO class (https://ruby-doc.org/core-2.3.1/IO.html)
+# source: https://gitlab.com/snippets/1685610
+module Gitlab
+ module Ci
+ class Trace
+ class ChunkedIO
+ CHUNK_SIZE = ::Ci::BuildTraceChunk::CHUNK_SIZE
+
+ FailedToGetChunkError = Class.new(StandardError)
+
+ attr_reader :build
+ attr_reader :tell, :size
+ attr_reader :chunk, :chunk_range
+
+ alias_method :pos, :tell
+
+ def initialize(build, &block)
+ @build = build
+ @chunks_cache = []
+ @tell = 0
+ @size = calculate_size
+ yield self if block_given?
+ end
+
+ def close
+ # no-op
+ end
+
+ def binmode
+ # no-op
+ end
+
+ def binmode?
+ true
+ end
+
+ def seek(pos, where = IO::SEEK_SET)
+ new_pos =
+ case where
+ when IO::SEEK_END
+ size + pos
+ when IO::SEEK_SET
+ pos
+ when IO::SEEK_CUR
+ tell + pos
+ else
+ -1
+ end
+
+ raise ArgumentError, 'new position is outside of file' if new_pos < 0 || new_pos > size
+
+ @tell = new_pos
+ end
+
+ def eof?
+ tell == size
+ end
+
+ def each_line
+ until eof?
+ line = readline
+ break if line.nil?
+
+ yield(line)
+ end
+ end
+
+ def read(length = nil, outbuf = "")
+ out = ""
+
+ length ||= size - tell
+
+ until length <= 0 || eof?
+ data = chunk_slice_from_offset
+ break if data.empty?
+
+ chunk_bytes = [CHUNK_SIZE - chunk_offset, length].min
+ chunk_data = data.byteslice(0, chunk_bytes)
+
+ out << chunk_data
+ @tell += chunk_data.bytesize
+ length -= chunk_data.bytesize
+ end
+
+ # If outbuf is passed, we put the output into the buffer. This supports IO.copy_stream functionality
+ if outbuf
+ outbuf.slice!(0, outbuf.bytesize)
+ outbuf << out
+ end
+
+ out
+ end
+
+ def readline
+ out = ""
+
+ until eof?
+ data = chunk_slice_from_offset
+ new_line = data.index("\n")
+
+ if !new_line.nil?
+ out << data[0..new_line]
+ @tell += new_line + 1
+ break
+ else
+ out << data
+ @tell += data.bytesize
+ end
+ end
+
+ out
+ end
+
+ def write(data)
+ start_pos = tell
+
+ while tell < start_pos + data.bytesize
+ # get slice from current offset till the end where it falls into chunk
+ chunk_bytes = CHUNK_SIZE - chunk_offset
+ chunk_data = data.byteslice(tell - start_pos, chunk_bytes)
+
+ # append data to chunk, overwriting from that point
+ ensure_chunk.append(chunk_data, chunk_offset)
+
+ # move offsets within buffer
+ @tell += chunk_data.bytesize
+ @size = [size, tell].max
+ end
+
+ tell - start_pos
+ ensure
+ invalidate_chunk_cache
+ end
+
+ def truncate(offset)
+ raise ArgumentError, 'Outside of file' if offset > size || offset < 0
+ return if offset == size # Skip the following process as it doesn't affect anything
+
+ @tell = offset
+ @size = offset
+
+ # remove all next chunks
+ trace_chunks.where('chunk_index > ?', chunk_index).fast_destroy_all
+
+ # truncate current chunk
+ current_chunk.truncate(chunk_offset)
+ ensure
+ invalidate_chunk_cache
+ end
+
+ def flush
+ # no-op
+ end
+
+ def present?
+ true
+ end
+
+ def destroy!
+ trace_chunks.fast_destroy_all
+ @tell = @size = 0
+ ensure
+ invalidate_chunk_cache
+ end
+
+ private
+
+ ##
+ # The below methods are not implemented in IO class
+ #
+ def in_range?
+ @chunk_range&.include?(tell)
+ end
+
+ def chunk_slice_from_offset
+ unless in_range?
+ current_chunk.tap do |chunk|
+ raise FailedToGetChunkError unless chunk
+
+ @chunk = chunk.data
+ @chunk_range = chunk.range
+ end
+ end
+
+ @chunk[chunk_offset..CHUNK_SIZE]
+ end
+
+ def chunk_offset
+ tell % CHUNK_SIZE
+ end
+
+ def chunk_index
+ tell / CHUNK_SIZE
+ end
+
+ def chunk_start
+ chunk_index * CHUNK_SIZE
+ end
+
+ def chunk_end
+ [chunk_start + CHUNK_SIZE, size].min
+ end
+
+ def invalidate_chunk_cache
+ @chunks_cache = []
+ end
+
+ def current_chunk
+ @chunks_cache[chunk_index] ||= trace_chunks.find_by(chunk_index: chunk_index)
+ end
+
+ def build_chunk
+ @chunks_cache[chunk_index] = ::Ci::BuildTraceChunk.new(build: build, chunk_index: chunk_index)
+ end
+
+ def ensure_chunk
+ current_chunk || build_chunk
+ end
+
+ def trace_chunks
+ ::Ci::BuildTraceChunk.where(build: build)
+ end
+
+ def calculate_size
+ trace_chunks.order(chunk_index: :desc).first.try(&:end_offset).to_i
+ end
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/ci/trace/stream.rb b/lib/gitlab/ci/trace/stream.rb
index 187ad8b833a..a71040e5e56 100644
--- a/lib/gitlab/ci/trace/stream.rb
+++ b/lib/gitlab/ci/trace/stream.rb
@@ -39,6 +39,8 @@ module Gitlab
end
def append(data, offset)
+ data = data.force_encoding(Encoding::BINARY)
+
stream.truncate(offset)
stream.seek(0, IO::SEEK_END)
stream.write(data)
@@ -46,8 +48,11 @@ module Gitlab
end
def set(data)
- truncate(0)
+ data = data.force_encoding(Encoding::BINARY)
+
+ stream.seek(0, IO::SEEK_SET)
stream.write(data)
+ stream.truncate(data.bytesize)
stream.flush()
end
@@ -127,11 +132,11 @@ module Gitlab
buf += debris
debris, *lines = buf.each_line.to_a
lines.reverse_each do |line|
- yield(line.force_encoding('UTF-8'))
+ yield(line.force_encoding(Encoding.default_external))
end
end
- yield(debris.force_encoding('UTF-8')) unless debris.empty?
+ yield(debris.force_encoding(Encoding.default_external)) unless debris.empty?
end
def read_backward(length)
diff --git a/lib/gitlab/git/raw_diff_change.rb b/lib/gitlab/git/raw_diff_change.rb
index 92f6c45ce25..6042e993113 100644
--- a/lib/gitlab/git/raw_diff_change.rb
+++ b/lib/gitlab/git/raw_diff_change.rb
@@ -6,7 +6,15 @@ module Gitlab
attr_reader :blob_id, :blob_size, :old_path, :new_path, :operation
def initialize(raw_change)
- parse(raw_change)
+ if raw_change.is_a?(Gitaly::GetRawChangesResponse::RawChange)
+ @blob_id = raw_change.blob_id
+ @blob_size = raw_change.size
+ @old_path = raw_change.old_path.presence
+ @new_path = raw_change.new_path.presence
+ @operation = raw_change.operation&.downcase || :unknown
+ else
+ parse(raw_change)
+ end
end
private
diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb
index 60ce8cfc195..bc61834ff7d 100644
--- a/lib/gitlab/git/repository.rb
+++ b/lib/gitlab/git/repository.rb
@@ -581,19 +581,30 @@ module Gitlab
# old_rev and new_rev are commit ID's
# the result of this method is an array of Gitlab::Git::RawDiffChange
def raw_changes_between(old_rev, new_rev)
- result = []
+ gitaly_migrate(:raw_changes_between) do |is_enabled|
+ if is_enabled
+ gitaly_repository_client.raw_changes_between(old_rev, new_rev)
+ .each_with_object([]) do |msg, arr|
+ msg.raw_changes.each { |change| arr << ::Gitlab::Git::RawDiffChange.new(change) }
+ end
+ else
+ result = []
- circuit_breaker.perform do
- Open3.pipeline_r(git_diff_cmd(old_rev, new_rev), format_git_cat_file_script, git_cat_file_cmd) do |last_stdout, wait_threads|
- last_stdout.each_line { |line| result << ::Gitlab::Git::RawDiffChange.new(line.chomp!) }
+ circuit_breaker.perform do
+ Open3.pipeline_r(git_diff_cmd(old_rev, new_rev), format_git_cat_file_script, git_cat_file_cmd) do |last_stdout, wait_threads|
+ last_stdout.each_line { |line| result << ::Gitlab::Git::RawDiffChange.new(line.chomp!) }
- if wait_threads.any? { |waiter| !waiter.value&.success? }
- raise ::Gitlab::Git::Repository::GitError, "Unabled to obtain changes between #{old_rev} and #{new_rev}"
+ if wait_threads.any? { |waiter| !waiter.value&.success? }
+ raise ::Gitlab::Git::Repository::GitError, "Unabled to obtain changes between #{old_rev} and #{new_rev}"
+ end
+ end
end
+
+ result
end
end
-
- result
+ rescue ArgumentError => e
+ raise Gitlab::Git::Repository::GitError.new(e)
end
# Returns the SHA of the most recent common ancestor of +from+ and +to+
diff --git a/lib/gitlab/gitaly_client/repository_service.rb b/lib/gitlab/gitaly_client/repository_service.rb
index 498187997e1..662b3d6cd0c 100644
--- a/lib/gitlab/gitaly_client/repository_service.rb
+++ b/lib/gitlab/gitaly_client/repository_service.rb
@@ -293,6 +293,12 @@ module Gitlab
response = GitalyClient.call(@storage, :repository_service, :calculate_checksum, request)
response.checksum.presence
end
+
+ def raw_changes_between(from, to)
+ request = Gitaly::GetRawChangesRequest.new(repository: @gitaly_repo, from_revision: from, to_revision: to)
+
+ GitalyClient.call(@storage, :repository_service, :get_raw_changes, request)
+ end
end
end
end
diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml
index 0d1c4f73c6e..21ac7f7e0b6 100644
--- a/lib/gitlab/import_export/import_export.yml
+++ b/lib/gitlab/import_export/import_export.yml
@@ -106,6 +106,7 @@ excluded_attributes:
- :last_repository_updated_at
- :last_repository_check_at
- :storage_version
+ - :remote_mirror_available_overridden
- :description_html
snippets:
- :expired_at
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index 8c0a4d55ea2..e294f3c4ebc 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -71,6 +71,7 @@ module Gitlab
projects_imported_from_github: Project.where(import_type: 'github').count,
protected_branches: ProtectedBranch.count,
releases: Release.count,
+ remote_mirrors: RemoteMirror.count,
snippets: Snippet.count,
todos: Todo.count,
uploads: Upload.count,
diff --git a/lib/tasks/migrate/add_limits_mysql.rake b/lib/tasks/migrate/add_limits_mysql.rake
index 151f42a2222..c6204f89de4 100644
--- a/lib/tasks/migrate/add_limits_mysql.rake
+++ b/lib/tasks/migrate/add_limits_mysql.rake
@@ -1,6 +1,7 @@
require Rails.root.join('db/migrate/limits_to_mysql')
require Rails.root.join('db/migrate/markdown_cache_limits_to_mysql')
require Rails.root.join('db/migrate/merge_request_diff_file_limits_to_mysql')
+require Rails.root.join('db/migrate/limits_ci_build_trace_chunks_raw_data_for_mysql')
desc "GitLab | Add limits to strings in mysql database"
task add_limits_mysql: :environment do
@@ -8,4 +9,5 @@ task add_limits_mysql: :environment do
LimitsToMysql.new.up
MarkdownCacheLimitsToMysql.new.up
MergeRequestDiffFileLimitsToMysql.new.up
+ LimitsCiBuildTraceChunksRawDataForMysql.new.up
end
diff --git a/package.json b/package.json
index e95add1d7e9..c2137ab8316 100644
--- a/package.json
+++ b/package.json
@@ -80,7 +80,7 @@
"three-orbit-controls": "^82.1.0",
"three-stl-loader": "^1.0.4",
"timeago.js": "^3.0.2",
- "underscore": "^1.8.3",
+ "underscore": "^1.9.0",
"url-loader": "^0.6.2",
"visibilityjs": "^1.2.4",
"vue": "^2.5.16",
diff --git a/spec/controllers/projects/compare_controller_spec.rb b/spec/controllers/projects/compare_controller_spec.rb
index 046ce027965..b15cde4314e 100644
--- a/spec/controllers/projects/compare_controller_spec.rb
+++ b/spec/controllers/projects/compare_controller_spec.rb
@@ -3,96 +3,99 @@ require 'spec_helper'
describe Projects::CompareController do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
- let(:ref_from) { "improve%2Fawesome" }
- let(:ref_to) { "feature" }
before do
sign_in(user)
project.add_master(user)
end
- it 'compare shows some diffs' do
- get(:show,
- namespace_id: project.namespace,
- project_id: project,
- from: ref_from,
- to: ref_to)
+ describe 'GET index' do
+ render_views
+
+ before do
+ get :index, namespace_id: project.namespace, project_id: project
+ end
- expect(response).to be_success
- expect(assigns(:diffs).diff_files.first).not_to be_nil
- expect(assigns(:commits).length).to be >= 1
+ it 'returns successfully' do
+ expect(response).to be_success
+ end
end
- it 'compare shows some diffs with ignore whitespace change option' do
- get(:show,
+ describe 'GET show' do
+ render_views
+
+ subject(:show_request) { get :show, request_params }
+
+ let(:request_params) do
+ {
namespace_id: project.namespace,
project_id: project,
- from: '08f22f25',
- to: '66eceea0',
- w: 1)
-
- expect(response).to be_success
- diff_file = assigns(:diffs).diff_files.first
- expect(diff_file).not_to be_nil
- expect(assigns(:commits).length).to be >= 1
- # without whitespace option, there are more than 2 diff_splits
- diff_splits = diff_file.diff.diff.split("\n")
- expect(diff_splits.length).to be <= 2
- end
+ from: source_ref,
+ to: target_ref,
+ w: whitespace
+ }
+ end
- describe 'non-existent refs' do
- it 'uses invalid source ref' do
- get(:show,
- namespace_id: project.namespace,
- project_id: project,
- from: 'non-existent',
- to: ref_to)
+ let(:whitespace) { nil }
- expect(response).to be_success
- expect(assigns(:diffs).diff_files.to_a).to eq([])
- expect(assigns(:commits)).to eq([])
- end
+ context 'when the refs exist' do
+ context 'when we set the white space param' do
+ let(:source_ref) { "08f22f25" }
+ let(:target_ref) { "66eceea0" }
+ let(:whitespace) { 1 }
- it 'uses invalid target ref' do
- get(:show,
- namespace_id: project.namespace,
- project_id: project,
- from: ref_from,
- to: 'non-existent')
+ it 'shows some diffs with ignore whitespace change option' do
+ show_request
- expect(response).to be_success
- expect(assigns(:diffs)).to eq(nil)
- expect(assigns(:commits)).to eq(nil)
- end
+ expect(response).to be_success
+ diff_file = assigns(:diffs).diff_files.first
+ expect(diff_file).not_to be_nil
+ expect(assigns(:commits).length).to be >= 1
+ # without whitespace option, there are more than 2 diff_splits
+ diff_splits = diff_file.diff.diff.split("\n")
+ expect(diff_splits.length).to be <= 2
+ end
+ end
+
+ context 'when we do not set the white space param' do
+ let(:source_ref) { "improve%2Fawesome" }
+ let(:target_ref) { "feature" }
+ let(:whitespace) { nil }
- it 'redirects back to index when params[:from] is empty and preserves params[:to]' do
- post(:create,
- namespace_id: project.namespace,
- project_id: project,
- from: '',
- to: 'master')
+ it 'sets the diffs and commits ivars' do
+ show_request
- expect(response).to redirect_to(project_compare_index_path(project, to: 'master'))
+ expect(response).to be_success
+ expect(assigns(:diffs).diff_files.first).not_to be_nil
+ expect(assigns(:commits).length).to be >= 1
+ end
+ end
end
- it 'redirects back to index when params[:to] is empty and preserves params[:from]' do
- post(:create,
- namespace_id: project.namespace,
- project_id: project,
- from: 'master',
- to: '')
+ context 'when the source ref does not exist' do
+ let(:source_ref) { 'non-existent-source-ref' }
+ let(:target_ref) { "feature" }
+
+ it 'sets empty diff and commit ivars' do
+ show_request
- expect(response).to redirect_to(project_compare_index_path(project, from: 'master'))
+ expect(response).to be_success
+ expect(assigns(:diffs).diff_files.to_a).to eq([])
+ expect(assigns(:commits)).to eq([])
+ end
end
- it 'redirects back to index when params[:from] and params[:to] are empty' do
- post(:create,
- namespace_id: project.namespace,
- project_id: project,
- from: '',
- to: '')
+ context 'when the target ref does not exist' do
+ let(:target_ref) { 'non-existent-target-ref' }
+ let(:source_ref) { "improve%2Fawesome" }
- expect(response).to redirect_to(namespace_project_compare_index_path)
+ it 'sets empty diff and commit ivars' do
+ show_request
+
+ expect(response).to be_success
+ expect(assigns(:diffs)).to eq([])
+ expect(assigns(:commits)).to eq([])
+ end
end
end
@@ -107,12 +110,14 @@ describe Projects::CompareController do
end
let(:existing_path) { 'files/ruby/feature.rb' }
+ let(:source_ref) { "improve%2Fawesome" }
+ let(:target_ref) { "feature" }
- context 'when the from and to refs exist' do
- context 'when the user has access to the project' do
+ context 'when the source and target refs exist' do
+ context 'when the user has access target the project' do
context 'when the path exists in the diff' do
it 'disables diff notes' do
- diff_for_path(from: ref_from, to: ref_to, old_path: existing_path, new_path: existing_path)
+ diff_for_path(from: source_ref, to: target_ref, old_path: existing_path, new_path: existing_path)
expect(assigns(:diff_notes_disabled)).to be_truthy
end
@@ -123,13 +128,13 @@ describe Projects::CompareController do
meth.call(diffs)
end
- diff_for_path(from: ref_from, to: ref_to, old_path: existing_path, new_path: existing_path)
+ diff_for_path(from: source_ref, to: target_ref, old_path: existing_path, new_path: existing_path)
end
end
context 'when the path does not exist in the diff' do
before do
- diff_for_path(from: ref_from, to: ref_to, old_path: existing_path.succ, new_path: existing_path.succ)
+ diff_for_path(from: source_ref, to: target_ref, old_path: existing_path.succ, new_path: existing_path.succ)
end
it 'returns a 404' do
@@ -138,10 +143,10 @@ describe Projects::CompareController do
end
end
- context 'when the user does not have access to the project' do
+ context 'when the user does not have access target the project' do
before do
project.team.truncate
- diff_for_path(from: ref_from, to: ref_to, old_path: existing_path, new_path: existing_path)
+ diff_for_path(from: source_ref, to: target_ref, old_path: existing_path, new_path: existing_path)
end
it 'returns a 404' do
@@ -150,9 +155,9 @@ describe Projects::CompareController do
end
end
- context 'when the from ref does not exist' do
+ context 'when the source ref does not exist' do
before do
- diff_for_path(from: ref_from.succ, to: ref_to, old_path: existing_path, new_path: existing_path)
+ diff_for_path(from: source_ref.succ, to: target_ref, old_path: existing_path, new_path: existing_path)
end
it 'returns a 404' do
@@ -160,9 +165,9 @@ describe Projects::CompareController do
end
end
- context 'when the to ref does not exist' do
+ context 'when the target ref does not exist' do
before do
- diff_for_path(from: ref_from, to: ref_to.succ, old_path: existing_path, new_path: existing_path)
+ diff_for_path(from: source_ref, to: target_ref.succ, old_path: existing_path, new_path: existing_path)
end
it 'returns a 404' do
@@ -170,4 +175,153 @@ describe Projects::CompareController do
end
end
end
+
+ describe 'POST create' do
+ subject(:create_request) { post :create, request_params }
+
+ let(:request_params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ from: source_ref,
+ to: target_ref
+ }
+ end
+
+ context 'when sending valid params' do
+ let(:source_ref) { "improve%2Fawesome" }
+ let(:target_ref) { "feature" }
+
+ it 'redirects back to show' do
+ create_request
+
+ expect(response).to redirect_to(project_compare_path(project, to: target_ref, from: source_ref))
+ end
+ end
+
+ context 'when sending invalid params' do
+ context 'when the source ref is empty and target ref is set' do
+ let(:source_ref) { '' }
+ let(:target_ref) { 'master' }
+
+ it 'redirects back to index and preserves the target ref' do
+ create_request
+
+ expect(response).to redirect_to(project_compare_index_path(project, to: target_ref))
+ end
+ end
+
+ context 'when the target ref is empty and source ref is set' do
+ let(:source_ref) { 'master' }
+ let(:target_ref) { '' }
+
+ it 'redirects back to index and preserves source ref' do
+ create_request
+
+ expect(response).to redirect_to(project_compare_index_path(project, from: source_ref))
+ end
+ end
+
+ context 'when the target and source ref are empty' do
+ let(:source_ref) { '' }
+ let(:target_ref) { '' }
+
+ it 'redirects back to index' do
+ create_request
+
+ expect(response).to redirect_to(namespace_project_compare_index_path)
+ end
+ end
+ end
+ end
+
+ describe 'GET signatures' do
+ subject(:signatures_request) { get :signatures, request_params }
+
+ let(:request_params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ from: source_ref,
+ to: target_ref,
+ format: :json
+ }
+ end
+
+ context 'when the source and target refs exist' do
+ let(:source_ref) { "improve%2Fawesome" }
+ let(:target_ref) { "feature" }
+
+ context 'when the user has access to the project' do
+ render_views
+
+ let(:signature_commit) { build(:commit, project: project, safe_message: "message", sha: 'signature_commit') }
+ let(:non_signature_commit) { build(:commit, project: project, safe_message: "message", sha: 'non_signature_commit') }
+
+ before do
+ escaped_source_ref = Addressable::URI.unescape(source_ref)
+ escaped_target_ref = Addressable::URI.unescape(target_ref)
+
+ compare_service = CompareService.new(project, escaped_target_ref)
+ compare = compare_service.execute(project, escaped_source_ref)
+
+ expect(CompareService).to receive(:new).with(project, escaped_target_ref).and_return(compare_service)
+ expect(compare_service).to receive(:execute).with(project, escaped_source_ref).and_return(compare)
+
+ expect(compare).to receive(:commits).and_return([signature_commit, non_signature_commit])
+ expect(non_signature_commit).to receive(:has_signature?).and_return(false)
+ end
+
+ it 'returns only the commit with a signature' do
+ signatures_request
+
+ expect(response).to have_gitlab_http_status(200)
+ parsed_body = JSON.parse(response.body)
+ signatures = parsed_body['signatures']
+
+ expect(signatures.size).to eq(1)
+ expect(signatures.first['commit_sha']).to eq(signature_commit.sha)
+ expect(signatures.first['html']).to be_present
+ end
+ end
+
+ context 'when the user does not have access to the project' do
+ before do
+ project.team.truncate
+ end
+
+ it 'returns a 404' do
+ signatures_request
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+ end
+
+ context 'when the source ref does not exist' do
+ let(:source_ref) { 'non-existent-ref-source' }
+ let(:target_ref) { "feature" }
+
+ it 'returns no signatures' do
+ signatures_request
+
+ expect(response).to have_gitlab_http_status(200)
+ parsed_body = JSON.parse(response.body)
+ expect(parsed_body['signatures']).to be_empty
+ end
+ end
+
+ context 'when the target ref does not exist' do
+ let(:target_ref) { 'non-existent-ref-target' }
+ let(:source_ref) { "improve%2Fawesome" }
+
+ it 'returns no signatures' do
+ signatures_request
+
+ expect(response).to have_gitlab_http_status(200)
+ parsed_body = JSON.parse(response.body)
+ expect(parsed_body['signatures']).to be_empty
+ end
+ end
+ end
end
diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb
index b9a979044fe..2281cb420d9 100644
--- a/spec/controllers/projects/jobs_controller_spec.rb
+++ b/spec/controllers/projects/jobs_controller_spec.rb
@@ -1,7 +1,7 @@
# coding: utf-8
require 'spec_helper'
-describe Projects::JobsController do
+describe Projects::JobsController, :clean_gitlab_redis_shared_state do
include ApiHelpers
include HttpIOHelpers
@@ -10,6 +10,7 @@ describe Projects::JobsController do
let(:user) { create(:user) }
before do
+ stub_feature_flags(ci_enable_live_trace: true)
stub_not_protect_default_branch
end
diff --git a/spec/controllers/projects/mirrors_controller_spec.rb b/spec/controllers/projects/mirrors_controller_spec.rb
new file mode 100644
index 00000000000..45c1218a39c
--- /dev/null
+++ b/spec/controllers/projects/mirrors_controller_spec.rb
@@ -0,0 +1,72 @@
+require 'spec_helper'
+
+describe Projects::MirrorsController do
+ include ReactiveCachingHelpers
+
+ describe 'setting up a remote mirror' do
+ set(:project) { create(:project, :repository) }
+
+ context 'when the current project is not a mirror' do
+ it 'allows to create a remote mirror' do
+ sign_in(project.owner)
+
+ expect do
+ do_put(project, remote_mirrors_attributes: { '0' => { 'enabled' => 1, 'url' => 'http://foo.com' } })
+ end.to change { RemoteMirror.count }.to(1)
+ end
+ end
+ end
+
+ describe '#update' do
+ let(:project) { create(:project, :repository, :remote_mirror) }
+
+ before do
+ sign_in(project.owner)
+ end
+
+ around do |example|
+ Sidekiq::Testing.fake! { example.run }
+ end
+
+ context 'With valid URL for a push' do
+ let(:remote_mirror_attributes) do
+ { "0" => { "enabled" => "0", url: 'https://updated.example.com' } }
+ end
+
+ it 'processes a successful update' do
+ do_put(project, remote_mirrors_attributes: remote_mirror_attributes)
+
+ expect(response).to redirect_to(project_settings_repository_path(project))
+ expect(flash[:notice]).to match(/successfully updated/)
+ end
+
+ it 'should create a RemoteMirror object' do
+ expect { do_put(project, remote_mirrors_attributes: remote_mirror_attributes) }.to change(RemoteMirror, :count).by(1)
+ end
+ end
+
+ context 'With invalid URL for a push' do
+ let(:remote_mirror_attributes) do
+ { "0" => { "enabled" => "0", url: 'ftp://invalid.invalid' } }
+ end
+
+ it 'processes an unsuccessful update' do
+ do_put(project, remote_mirrors_attributes: remote_mirror_attributes)
+
+ expect(response).to redirect_to(project_settings_repository_path(project))
+ expect(flash[:alert]).to match(/must be a valid URL/)
+ end
+
+ it 'should not create a RemoteMirror object' do
+ expect { do_put(project, remote_mirrors_attributes: remote_mirror_attributes) }.not_to change(RemoteMirror, :count)
+ end
+ end
+ end
+
+ def do_put(project, options, extra_attrs = {})
+ attrs = extra_attrs.merge(namespace_id: project.namespace.to_param, project_id: project.to_param)
+ attrs[:project] = options
+
+ put :update, attrs
+ end
+end
diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb
index 35ac999cc65..a451bbb97b6 100644
--- a/spec/controllers/projects/pipelines_controller_spec.rb
+++ b/spec/controllers/projects/pipelines_controller_spec.rb
@@ -109,8 +109,7 @@ describe Projects::PipelinesController do
it 'returns html source for stage dropdown' do
expect(response).to have_gitlab_http_status(:ok)
- expect(response).to render_template('projects/pipelines/_stage')
- expect(json_response).to include('html')
+ expect(response).to match_response_schema('pipeline_stage')
end
end
@@ -133,6 +132,42 @@ describe Projects::PipelinesController do
end
end
+ describe 'GET stages_ajax.json' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+
+ context 'when accessing existing stage' do
+ before do
+ create(:ci_build, pipeline: pipeline, stage: 'build')
+
+ get_stage_ajax('build')
+ end
+
+ it 'returns html source for stage dropdown' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template('projects/pipelines/_stage')
+ expect(json_response).to include('html')
+ end
+ end
+
+ context 'when accessing unknown stage' do
+ before do
+ get_stage_ajax('test')
+ end
+
+ it 'responds with not found' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ def get_stage_ajax(name)
+ get :stage_ajax, namespace_id: project.namespace,
+ project_id: project,
+ id: pipeline.id,
+ stage: name,
+ format: :json
+ end
+ end
+
describe 'GET status.json' do
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:status) { pipeline.detailed_status(double('user')) }
diff --git a/spec/db/production/settings_spec.rb b/spec/db/production/settings_spec.rb
index c8d016070f5..db19e98b851 100644
--- a/spec/db/production/settings_spec.rb
+++ b/spec/db/production/settings_spec.rb
@@ -48,15 +48,15 @@ describe 'seed production settings' do
end
end
- context 'GITLAB_PROMETHEUS_METRICS_ENABLED is false' do
+ context 'GITLAB_PROMETHEUS_METRICS_ENABLED is default' do
before do
stub_env('GITLAB_PROMETHEUS_METRICS_ENABLED', '')
end
- it 'prometheus_metrics_enabled is set to false' do
+ it 'prometheus_metrics_enabled is set to true' do
load(settings_file)
- expect(settings.prometheus_metrics_enabled).to eq(false)
+ expect(settings.prometheus_metrics_enabled).to eq(true)
end
end
end
diff --git a/spec/factories/ci/build_trace_chunks.rb b/spec/factories/ci/build_trace_chunks.rb
new file mode 100644
index 00000000000..c0b9a25bfe8
--- /dev/null
+++ b/spec/factories/ci/build_trace_chunks.rb
@@ -0,0 +1,7 @@
+FactoryBot.define do
+ factory :ci_build_trace_chunk, class: Ci::BuildTraceChunk do
+ build factory: :ci_build
+ chunk_index 0
+ data_store :redis
+ end
+end
diff --git a/spec/factories/project_wikis.rb b/spec/factories/project_wikis.rb
index db2eb4fc863..4d21ed47f39 100644
--- a/spec/factories/project_wikis.rb
+++ b/spec/factories/project_wikis.rb
@@ -2,7 +2,7 @@ FactoryBot.define do
factory :project_wiki do
skip_create
- project
+ association :project, :wiki_repo
user { project.creator }
initialize_with { new(project, user) }
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index a6128903546..d129815aeac 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -183,6 +183,17 @@ FactoryBot.define do
end
end
+ trait :remote_mirror do
+ transient do
+ remote_name "remote_mirror_#{SecureRandom.hex}"
+ url "http://foo.com"
+ enabled true
+ end
+ after(:create) do |project, evaluator|
+ project.remote_mirrors.create!(url: evaluator.url, enabled: evaluator.enabled)
+ end
+ end
+
trait :stubbed_repository do
after(:build) do |project|
allow(project).to receive(:empty_repo?).and_return(false)
@@ -193,6 +204,13 @@ FactoryBot.define do
trait :wiki_repo do
after(:create) do |project|
raise 'Failed to create wiki repository!' unless project.create_wiki
+
+ # We delete hooks so that gitlab-shell will not try to authenticate with
+ # an API that isn't running
+ project.gitlab_shell.rm_directory(
+ project.repository_storage,
+ File.join("#{project.wiki.repository.disk_path}.git", "hooks")
+ )
end
end
diff --git a/spec/factories/remote_mirrors.rb b/spec/factories/remote_mirrors.rb
new file mode 100644
index 00000000000..adc7da27522
--- /dev/null
+++ b/spec/factories/remote_mirrors.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :remote_mirror, class: 'RemoteMirror' do
+ association :project, :repository
+ url "http://foo:bar@test.com"
+ end
+end
diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb
index a00db6dd161..9d1c4cbad8b 100644
--- a/spec/features/projects/jobs_spec.rb
+++ b/spec/features/projects/jobs_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
require 'tempfile'
-feature 'Jobs' do
+feature 'Jobs', :clean_gitlab_redis_shared_state do
let(:user) { create(:user) }
let(:user_access_level) { :developer }
let(:project) { create(:project, :repository) }
@@ -282,7 +282,7 @@ feature 'Jobs' do
it 'loads job trace' do
expect(page).to have_content 'BUILD TRACE'
- job.trace.write do |stream|
+ job.trace.write('a+b') do |stream|
stream.append(' and more trace', 11)
end
@@ -593,44 +593,6 @@ feature 'Jobs' do
end
end
- context 'storage form' do
- let(:existing_file) { Tempfile.new('existing-trace-file').path }
-
- before do
- job.run!
- end
-
- context 'when job has trace in file', :js do
- before do
- allow_any_instance_of(Gitlab::Ci::Trace)
- .to receive(:paths)
- .and_return([existing_file])
- end
-
- it 'sends the right headers' do
- requests = inspect_requests(inject_headers: { 'X-Sendfile-Type' => 'X-Sendfile' }) do
- visit raw_project_job_path(project, job)
- end
- expect(requests.first.response_headers['Content-Type']).to eq('text/plain; charset=utf-8')
- expect(requests.first.response_headers['X-Sendfile']).to eq(existing_file)
- end
- end
-
- context 'when job has trace in the database', :js do
- before do
- allow_any_instance_of(Gitlab::Ci::Trace)
- .to receive(:paths)
- .and_return([])
-
- visit project_job_path(project, job)
- end
-
- it 'sends the right headers' do
- expect(page).not_to have_selector('.js-raw-link-controller')
- end
- end
- end
-
context "when visiting old URL" do
let(:raw_job_url) do
raw_project_job_path(project, job)
diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb
index d404bc66ba8..90e28483c6c 100644
--- a/spec/features/projects/pipelines/pipelines_spec.rb
+++ b/spec/features/projects/pipelines/pipelines_spec.rb
@@ -388,9 +388,9 @@ describe 'Pipelines', :js do
it 'should be possible to cancel pending build' do
find('.js-builds-dropdown-button').click
- find('a.js-ci-action-icon').click
+ find('.js-ci-action').click
+ wait_for_requests
- expect(page).to have_content('canceled')
expect(build.reload).to be_canceled
end
end
@@ -407,7 +407,7 @@ describe 'Pipelines', :js do
within('.js-builds-dropdown-list') do
build_element = page.find('.mini-pipeline-graph-dropdown-item')
- expect(build_element['data-title']).to eq('build - failed <br> (unknown failure)')
+ expect(build_element['data-original-title']).to eq('build - failed <br> (unknown failure)')
end
end
end
diff --git a/spec/features/projects/remote_mirror_spec.rb b/spec/features/projects/remote_mirror_spec.rb
new file mode 100644
index 00000000000..81a6b613cc8
--- /dev/null
+++ b/spec/features/projects/remote_mirror_spec.rb
@@ -0,0 +1,34 @@
+require 'spec_helper'
+
+feature 'Project remote mirror', :feature do
+ let(:project) { create(:project, :repository, :remote_mirror) }
+ let(:remote_mirror) { project.remote_mirrors.first }
+ let(:user) { create(:user) }
+
+ describe 'On a project', :js do
+ before do
+ project.add_master(user)
+ sign_in user
+ end
+
+ context 'when last_error is present but last_update_at is not' do
+ it 'renders error message without timstamp' do
+ remote_mirror.update_attributes(last_error: 'Some new error', last_update_at: nil)
+
+ visit project_mirror_path(project)
+
+ expect(page).to have_content('The remote repository failed to update.')
+ end
+ end
+
+ context 'when last_error and last_update_at are present' do
+ it 'renders error message with timestamp' do
+ remote_mirror.update_attributes(last_error: 'Some new error', last_update_at: Time.now - 5.minutes)
+
+ visit project_mirror_path(project)
+
+ expect(page).to have_content('The remote repository failed to update 5 minutes ago.')
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb
index e1dfe617691..162aee63942 100644
--- a/spec/features/projects/settings/repository_settings_spec.rb
+++ b/spec/features/projects/settings/repository_settings_spec.rb
@@ -115,5 +115,20 @@ describe 'Projects > Settings > Repository settings' do
expect(page).to have_content('Your new project deploy token has been created')
end
end
+
+ context 'remote mirror settings' do
+ let(:user2) { create(:user) }
+
+ before do
+ project.add_master(user2)
+
+ visit project_settings_repository_path(project)
+ end
+
+ it 'shows push mirror settings' do
+ expect(page).to have_selector('#project_remote_mirrors_attributes_0_enabled')
+ expect(page).to have_selector('#project_remote_mirrors_attributes_0_url')
+ end
+ end
end
end
diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb
index b242e41df1c..3017048e506 100644
--- a/spec/features/projects/tree/create_directory_spec.rb
+++ b/spec/features/projects/tree/create_directory_spec.rb
@@ -44,12 +44,17 @@ feature 'Multi-file editor new directory', :js do
wait_for_requests
- click_button 'Stage all'
+ find('.js-ide-commit-mode').click
+
+ find('.multi-file-commit-list-item').hover
+ first('.multi-file-discard-btn .btn').click
fill_in('commit-message', with: 'commit message ide')
click_button('Commit')
+ find('.js-ide-edit-mode').click
+
expect(page).to have_content('folder name')
end
end
diff --git a/spec/features/projects/tree/create_file_spec.rb b/spec/features/projects/tree/create_file_spec.rb
index 7d65456e049..56471c8e7aa 100644
--- a/spec/features/projects/tree/create_file_spec.rb
+++ b/spec/features/projects/tree/create_file_spec.rb
@@ -34,7 +34,10 @@ feature 'Multi-file editor new file', :js do
wait_for_requests
- click_button 'Stage all'
+ find('.js-ide-commit-mode').click
+
+ find('.multi-file-commit-list-item').hover
+ first('.multi-file-discard-btn .btn').click
fill_in('commit-message', with: 'commit message ide')
diff --git a/spec/features/projects/wiki/markdown_preview_spec.rb b/spec/features/projects/wiki/markdown_preview_spec.rb
index 006c15d60c5..6586ccaa400 100644
--- a/spec/features/projects/wiki/markdown_preview_spec.rb
+++ b/spec/features/projects/wiki/markdown_preview_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
feature 'Projects > Wiki > User previews markdown changes', :js do
let(:user) { create(:user) }
- let(:project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
let(:wiki_content) do
<<-HEREDOC
[regular link](regular)
diff --git a/spec/features/projects/wiki/shortcuts_spec.rb b/spec/features/projects/wiki/shortcuts_spec.rb
index f70d1e710dd..6178361082e 100644
--- a/spec/features/projects/wiki/shortcuts_spec.rb
+++ b/spec/features/projects/wiki/shortcuts_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
feature 'Wiki shortcuts', :js do
let(:user) { create(:user) }
- let(:project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
let(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: { title: 'home', content: 'Home page' }) }
before do
diff --git a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb
index fe6fa55fa75..9989e1ffda7 100644
--- a/spec/features/projects/wiki/user_creates_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_creates_wiki_page_spec.rb
@@ -12,7 +12,7 @@ describe "User creates wiki page" do
context "when wiki is empty" do
context "in a user namespace" do
- let(:project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
it "shows validation error message" do
page.within(".wiki-form") do
@@ -142,7 +142,7 @@ describe "User creates wiki page" do
end
context "in a group namespace", :js do
- let(:project) { create(:project, namespace: create(:group, :public)) }
+ let(:project) { create(:project, :wiki_repo, namespace: create(:group, :public)) }
it "has `Create home` as a commit message" do
expect(page).to have_field("wiki[message]", with: "Create home")
@@ -164,11 +164,11 @@ describe "User creates wiki page" do
context "when wiki is not empty", :js do
before do
- create(:wiki_page, wiki: create(:project, namespace: user.namespace).wiki, attrs: { title: "home", content: "Home page" })
+ create(:wiki_page, wiki: create(:project, :wiki_repo, namespace: user.namespace).wiki, attrs: { title: "home", content: "Home page" })
end
context "in a user namespace" do
- let(:project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
context "via the `new wiki page` page" do
it "creates a page with a single word" do
@@ -261,7 +261,7 @@ describe "User creates wiki page" do
end
context "in a group namespace" do
- let(:project) { create(:project, namespace: create(:group, :public)) }
+ let(:project) { create(:project, :wiki_repo, namespace: create(:group, :public)) }
context "via the `new wiki page` page" do
it "creates a page" do
diff --git a/spec/features/projects/wiki/user_deletes_wiki_page_spec.rb b/spec/features/projects/wiki/user_deletes_wiki_page_spec.rb
index 605e332196b..ab9420fc38f 100644
--- a/spec/features/projects/wiki/user_deletes_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_deletes_wiki_page_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
feature 'User deletes wiki page' do
let(:user) { create(:user) }
- let(:project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
let(:wiki_page) { create(:wiki_page, wiki: project.wiki) }
before do
diff --git a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb
index 37a118c34ab..823399ac3c3 100644
--- a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe 'Projects > Wiki > User views Git access wiki page' do
let(:user) { create(:user) }
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :wiki_repo, :public) }
let(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: { title: 'home', content: '[some link](other-page)' }) }
before do
diff --git a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb
index ef1bb712846..e019e3ce5a5 100644
--- a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb
@@ -14,7 +14,7 @@ describe 'User updates wiki page' do
end
context 'in a user namespace' do
- let(:project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
it 'redirects back to the home edit page' do
page.within(:css, '.wiki-form .form-actions') do
@@ -66,7 +66,7 @@ describe 'User updates wiki page' do
end
context 'in a user namespace' do
- let(:project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
it 'updates a page' do
click_link('Edit')
@@ -134,7 +134,7 @@ describe 'User updates wiki page' do
end
context 'in a group namespace' do
- let(:project) { create(:project, namespace: create(:group, :public)) }
+ let(:project) { create(:project, :wiki_repo, namespace: create(:group, :public)) }
it 'updates a page' do
click_link('Edit')
@@ -154,7 +154,7 @@ describe 'User updates wiki page' do
end
context 'when the page is in a subdir' do
- let!(:project) { create(:project, namespace: user.namespace) }
+ let!(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
let(:project_wiki) { create(:project_wiki, project: project, user: project.creator) }
let(:page_name) { 'page_name' }
let(:page_dir) { "foo/bar/#{page_name}" }
diff --git a/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb b/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb
index 2682b62fa04..92b50169476 100644
--- a/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb
+++ b/spec/features/projects/wiki/user_views_wiki_in_project_page_spec.rb
@@ -11,6 +11,7 @@ describe 'Projects > Wiki > User views wiki in project page' do
context 'when repository is disabled for project' do
let(:project) do
create(:project,
+ :wiki_repo,
:repository_disabled,
:merge_requests_disabled,
:builds_disabled)
diff --git a/spec/features/projects/wiki/user_views_wiki_page_spec.rb b/spec/features/projects/wiki/user_views_wiki_page_spec.rb
index 306e382119a..6661714222a 100644
--- a/spec/features/projects/wiki/user_views_wiki_page_spec.rb
+++ b/spec/features/projects/wiki/user_views_wiki_page_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe 'User views a wiki page' do
shared_examples 'wiki page user view' do
let(:user) { create(:user) }
- let(:project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
let(:wiki_page) do
create(:wiki_page,
wiki: project.wiki,
diff --git a/spec/features/search/user_searches_for_wiki_pages_spec.rb b/spec/features/search/user_searches_for_wiki_pages_spec.rb
index 7934779058f..5098fb49ee1 100644
--- a/spec/features/search/user_searches_for_wiki_pages_spec.rb
+++ b/spec/features/search/user_searches_for_wiki_pages_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe 'User searches for wiki pages', :js do
let(:user) { create(:user) }
- let(:project) { create(:project, namespace: user.namespace) }
+ let(:project) { create(:project, :wiki_repo, namespace: user.namespace) }
let!(:wiki_page) { create(:wiki_page, wiki: project.wiki, attrs: { title: 'test_wiki', content: 'Some Wiki content' }) }
before do
diff --git a/spec/fixtures/api/schemas/ci_detailed_status.json b/spec/fixtures/api/schemas/ci_detailed_status.json
new file mode 100644
index 00000000000..01e34249bf1
--- /dev/null
+++ b/spec/fixtures/api/schemas/ci_detailed_status.json
@@ -0,0 +1,24 @@
+{
+ "type": "object",
+ "required" : [
+ "icon",
+ "text",
+ "label",
+ "group",
+ "tooltip",
+ "has_details",
+ "details_path",
+ "favicon"
+ ],
+ "properties": {
+ "icon": { "type": "string" },
+ "text": { "type": "string" },
+ "label": { "type": "string" },
+ "group": { "type": "string" },
+ "tooltip": { "type": "string" },
+ "has_details": { "type": "boolean" },
+ "details_path": { "type": "string" },
+ "favicon": { "type": "string" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/job.json b/spec/fixtures/api/schemas/job.json
new file mode 100644
index 00000000000..7b92ab25bc1
--- /dev/null
+++ b/spec/fixtures/api/schemas/job.json
@@ -0,0 +1,24 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "started",
+ "build_path",
+ "playable",
+ "created_at",
+ "updated_at",
+ "status"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "started": { "type": "boolean" } ,
+ "build_path": { "type": "string" },
+ "playable": { "type": "boolean" },
+ "created_at": { "type": "string" },
+ "updated_at": { "type": "string" },
+ "status": { "$ref": "ci_detailed_status.json" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/pipeline_stage.json b/spec/fixtures/api/schemas/pipeline_stage.json
new file mode 100644
index 00000000000..55454200bb3
--- /dev/null
+++ b/spec/fixtures/api/schemas/pipeline_stage.json
@@ -0,0 +1,24 @@
+{
+ "type": "object",
+ "required" : [
+ "name",
+ "title",
+ "status",
+ "path",
+ "dropdown_path"
+ ],
+ "properties" : {
+ "name": { "type": "string" },
+ "title": { "type": "string" },
+ "groups": { "optional": true },
+ "latest_statuses": {
+ "type": "array",
+ "items": { "$ref": "job.json" },
+ "optional": true
+ },
+ "status": { "$ref": "ci_detailed_status.json" },
+ "path": { "type": "string" },
+ "dropdown_path": { "type": "string" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
index 5e454f8b310..593b2ca1825 100644
--- a/spec/helpers/application_helper_spec.rb
+++ b/spec/helpers/application_helper_spec.rb
@@ -151,4 +151,16 @@ describe ApplicationHelper do
end
end
end
+
+ describe '#autocomplete_data_sources' do
+ let(:project) { create(:project) }
+ let(:noteable_type) { Issue }
+ it 'returns paths for autocomplete_sources_controller' do
+ sources = helper.autocomplete_data_sources(project, noteable_type)
+ expect(sources.keys).to match_array([:members, :issues, :merge_requests, :labels, :milestones, :commands])
+ sources.keys.each do |key|
+ expect(sources[key]).not_to be_nil
+ end
+ end
+ end
end
diff --git a/spec/javascripts/fixtures/mini_dropdown_graph.html.haml b/spec/javascripts/fixtures/mini_dropdown_graph.html.haml
index b532b48a95b..74584993739 100644
--- a/spec/javascripts/fixtures/mini_dropdown_graph.html.haml
+++ b/spec/javascripts/fixtures/mini_dropdown_graph.html.haml
@@ -4,6 +4,7 @@
%ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container
%li.js-builds-dropdown-list.scrollable-menu
+ %ul
%li.js-builds-dropdown-loading.hidden
%span.fa.fa-spinner
diff --git a/spec/javascripts/gpg_badges_spec.js b/spec/javascripts/gpg_badges_spec.js
index 5decb5e6bbd..97c771dcfd3 100644
--- a/spec/javascripts/gpg_badges_spec.js
+++ b/spec/javascripts/gpg_badges_spec.js
@@ -16,8 +16,8 @@ describe('GpgBadges', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
setFixtures(`
- <form
- class="commits-search-form" data-signatures-path="/hello" action="/hello"
+ <form
+ class="commits-search-form js-signature-container" data-signatures-path="/hello" action="/hello"
method="get">
<input name="utf8" type="hidden" value="✓">
<input type="search" name="search" id="commits-search"class="form-control search-text-input input-short">
diff --git a/spec/javascripts/ide/components/activity_bar_spec.js b/spec/javascripts/ide/components/activity_bar_spec.js
new file mode 100644
index 00000000000..946c7e8e9c8
--- /dev/null
+++ b/spec/javascripts/ide/components/activity_bar_spec.js
@@ -0,0 +1,92 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import { activityBarViews } from '~/ide/constants';
+import ActivityBar from '~/ide/components/activity_bar.vue';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { resetStore } from '../helpers';
+
+describe('IDE activity bar', () => {
+ const Component = Vue.extend(ActivityBar);
+ let vm;
+
+ beforeEach(() => {
+ Vue.set(store.state.projects, 'abcproject', {
+ web_url: 'testing',
+ });
+ Vue.set(store.state, 'currentProjectId', 'abcproject');
+
+ vm = createComponentWithStore(Component, store);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ describe('goBackUrl', () => {
+ it('renders the Go Back link with the referrer when present', () => {
+ const fakeReferrer = '/example/README.md';
+ spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
+
+ vm.$mount();
+
+ expect(vm.goBackUrl).toEqual(fakeReferrer);
+ });
+
+ it('renders the Go Back link with the project url when referrer is not present', () => {
+ const fakeReferrer = '';
+ spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
+
+ vm.$mount();
+
+ expect(vm.goBackUrl).toEqual('testing');
+ });
+ });
+
+ describe('updateActivityBarView', () => {
+ beforeEach(() => {
+ spyOn(vm, 'updateActivityBarView');
+
+ vm.$mount();
+ });
+
+ it('calls updateActivityBarView with edit value on click', () => {
+ vm.$el.querySelector('.js-ide-edit-mode').click();
+
+ expect(vm.updateActivityBarView).toHaveBeenCalledWith(activityBarViews.edit);
+ });
+
+ it('calls updateActivityBarView with commit value on click', () => {
+ vm.$el.querySelector('.js-ide-commit-mode').click();
+
+ expect(vm.updateActivityBarView).toHaveBeenCalledWith(activityBarViews.commit);
+ });
+
+ it('calls updateActivityBarView with review value on click', () => {
+ vm.$el.querySelector('.js-ide-review-mode').click();
+
+ expect(vm.updateActivityBarView).toHaveBeenCalledWith(activityBarViews.review);
+ });
+ });
+
+ describe('active item', () => {
+ beforeEach(() => {
+ vm.$mount();
+ });
+
+ it('sets edit item active', () => {
+ expect(vm.$el.querySelector('.js-ide-edit-mode').classList).toContain('active');
+ });
+
+ it('sets commit item active', done => {
+ vm.$store.state.currentActivityView = activityBarViews.commit;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.js-ide-commit-mode').classList).toContain('active');
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/commit_sidebar/empty_state_spec.js b/spec/javascripts/ide/components/commit_sidebar/empty_state_spec.js
index 53275b78da5..16d0b354a30 100644
--- a/spec/javascripts/ide/components/commit_sidebar/empty_state_spec.js
+++ b/spec/javascripts/ide/components/commit_sidebar/empty_state_spec.js
@@ -10,10 +10,9 @@ describe('IDE commit panel empty state', () => {
beforeEach(() => {
const Component = Vue.extend(emptyState);
- vm = createComponentWithStore(Component, store, {
- noChangesStateSvgPath: 'no-changes',
- committedStateSvgPath: 'committed-state',
- });
+ Vue.set(store.state, 'noChangesStateSvgPath', 'no-changes');
+
+ vm = createComponentWithStore(Component, store);
vm.$mount();
});
@@ -27,37 +26,4 @@ describe('IDE commit panel empty state', () => {
it('renders no changes text when last commit message is empty', () => {
expect(vm.$el.textContent).toContain('No changes');
});
-
- describe('toggle button', () => {
- it('calls store action', () => {
- spyOn(vm, 'toggleRightPanelCollapsed');
-
- vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click();
-
- expect(vm.toggleRightPanelCollapsed).toHaveBeenCalled();
- });
-
- it('renders collapsed class', done => {
- vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click();
-
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull();
-
- done();
- });
- });
- });
-
- describe('collapsed state', () => {
- beforeEach(done => {
- vm.$store.state.rightPanelCollapsed = true;
-
- Vue.nextTick(done);
- });
-
- it('does not render text & svg', () => {
- expect(vm.$el.querySelector('img')).toBeNull();
- expect(vm.$el.textContent).not.toContain('No changes');
- });
- });
});
diff --git a/spec/javascripts/ide/components/commit_sidebar/form_spec.js b/spec/javascripts/ide/components/commit_sidebar/form_spec.js
new file mode 100644
index 00000000000..ce7c134bc97
--- /dev/null
+++ b/spec/javascripts/ide/components/commit_sidebar/form_spec.js
@@ -0,0 +1,146 @@
+import Vue from 'vue';
+import store from '~/ide/stores';
+import CommitForm from '~/ide/components/commit_sidebar/form.vue';
+import { activityBarViews } from '~/ide/constants';
+import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
+import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
+import { resetStore } from '../../helpers';
+
+describe('IDE commit form', () => {
+ const Component = Vue.extend(CommitForm);
+ let vm;
+
+ beforeEach(() => {
+ spyOnProperty(window, 'innerHeight').and.returnValue(800);
+
+ store.state.changedFiles.push('test');
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('enables button when has changes', () => {
+ expect(vm.$el.querySelector('[disabled]')).toBe(null);
+ });
+
+ describe('compact', () => {
+ it('renders commit button in compact mode', () => {
+ expect(vm.$el.querySelector('.btn-primary')).not.toBeNull();
+ expect(vm.$el.querySelector('.btn-primary').textContent).toContain('Commit');
+ });
+
+ it('does not render form', () => {
+ expect(vm.$el.querySelector('form')).toBeNull();
+ });
+
+ it('renders overview text', done => {
+ vm.$store.state.stagedFiles.push('test');
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('p').textContent).toContain('1 unstaged and 1 staged changes');
+ done();
+ });
+ });
+
+ it('shows form when clicking commit button', done => {
+ vm.$el.querySelector('.btn-primary').click();
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('form')).not.toBeNull();
+
+ done();
+ });
+ });
+
+ it('toggles activity bar vie when clicking commit button', done => {
+ vm.$el.querySelector('.btn-primary').click();
+
+ vm.$nextTick(() => {
+ expect(store.state.currentActivityView).toBe(activityBarViews.commit);
+
+ done();
+ });
+ });
+ });
+
+ describe('full', () => {
+ beforeEach(done => {
+ vm.isCompact = false;
+
+ vm.$nextTick(done);
+ });
+
+ it('updates commitMessage in store on input', done => {
+ const textarea = vm.$el.querySelector('textarea');
+
+ textarea.value = 'testing commit message';
+
+ textarea.dispatchEvent(new Event('input'));
+
+ getSetTimeoutPromise()
+ .then(() => {
+ expect(vm.$store.state.commit.commitMessage).toBe('testing commit message');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('updating currentActivityView not to commit view sets compact mode', done => {
+ store.state.currentActivityView = 'a';
+
+ vm.$nextTick(() => {
+ expect(vm.isCompact).toBe(true);
+
+ done();
+ });
+ });
+
+ describe('discard draft button', () => {
+ it('hidden when commitMessage is empty', () => {
+ expect(vm.$el.querySelector('.btn-default').textContent).toContain('Collapse');
+ });
+
+ it('resets commitMessage when clicking discard button', done => {
+ vm.$store.state.commit.commitMessage = 'testing commit message';
+
+ getSetTimeoutPromise()
+ .then(() => {
+ vm.$el.querySelector('.btn-default').click();
+ })
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(vm.$store.state.commit.commitMessage).not.toBe('testing commit message');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('when submitting', () => {
+ beforeEach(() => {
+ spyOn(vm, 'commitChanges');
+ vm.$store.state.stagedFiles.push('test');
+ });
+
+ it('calls commitChanges', done => {
+ vm.$store.state.commit.commitMessage = 'testing commit message';
+
+ getSetTimeoutPromise()
+ .then(() => {
+ vm.$el.querySelector('.btn-success').click();
+ })
+ .then(Vue.nextTick)
+ .then(() => {
+ expect(vm.commitChanges).toHaveBeenCalled();
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/commit_sidebar/list_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_spec.js
index 62fc3f90ad1..54625ef90f8 100644
--- a/spec/javascripts/ide/components/commit_sidebar/list_spec.js
+++ b/spec/javascripts/ide/components/commit_sidebar/list_spec.js
@@ -49,45 +49,4 @@ describe('Multi-file editor commit sidebar list', () => {
expect(vm.$el.textContent).toContain('No changes');
});
});
-
- describe('collapsed', () => {
- beforeEach(done => {
- vm.$store.state.rightPanelCollapsed = true;
-
- Vue.nextTick(done);
- });
-
- it('hides list', () => {
- expect(vm.$el.querySelector('.list-unstyled')).toBeNull();
- expect(vm.$el.querySelector('.help-block')).toBeNull();
- });
- });
-
- describe('with toggle', () => {
- beforeEach(done => {
- spyOn(vm, 'toggleRightPanelCollapsed');
-
- vm.showToggle = true;
-
- Vue.nextTick(done);
- });
-
- it('calls setPanelCollapsedStatus when clickin toggle', () => {
- vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click();
-
- expect(vm.toggleRightPanelCollapsed).toHaveBeenCalled();
- });
- });
-
- describe('action button', () => {
- beforeEach(() => {
- spyOn(vm, 'stageAllChanges');
- });
-
- it('calls store action when clicked', () => {
- vm.$el.querySelector('.ide-staged-action-btn').click();
-
- expect(vm.stageAllChanges).toHaveBeenCalled();
- });
- });
});
diff --git a/spec/javascripts/ide/components/ide_context_bar_spec.js b/spec/javascripts/ide/components/ide_context_bar_spec.js
deleted file mode 100644
index e17b051f137..00000000000
--- a/spec/javascripts/ide/components/ide_context_bar_spec.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import Vue from 'vue';
-import store from '~/ide/stores';
-import ideContextBar from '~/ide/components/ide_context_bar.vue';
-import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-
-describe('Multi-file editor right context bar', () => {
- let vm;
-
- beforeEach(() => {
- const Component = Vue.extend(ideContextBar);
-
- vm = createComponentWithStore(Component, store, {
- noChangesStateSvgPath: 'svg',
- committedStateSvgPath: 'svg',
- });
-
- vm.$store.state.rightPanelCollapsed = false;
-
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('collapsed', () => {
- beforeEach(done => {
- vm.$store.state.rightPanelCollapsed = true;
-
- Vue.nextTick(done);
- });
-
- it('adds collapsed class', () => {
- expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull();
- });
- });
-});
diff --git a/spec/javascripts/ide/components/ide_external_links_spec.js b/spec/javascripts/ide/components/ide_external_links_spec.js
deleted file mode 100644
index 9f6cb459f3b..00000000000
--- a/spec/javascripts/ide/components/ide_external_links_spec.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import Vue from 'vue';
-import ideExternalLinks from '~/ide/components/ide_external_links.vue';
-import createComponent from 'spec/helpers/vue_mount_component_helper';
-
-describe('ide external links component', () => {
- let vm;
- let fakeReferrer;
- let Component;
-
- const fakeProjectUrl = '/project/';
-
- beforeEach(() => {
- Component = Vue.extend(ideExternalLinks);
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- describe('goBackUrl', () => {
- it('renders the Go Back link with the referrer when present', () => {
- fakeReferrer = '/example/README.md';
- spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
-
- vm = createComponent(Component, {
- projectUrl: fakeProjectUrl,
- }).$mount();
-
- expect(vm.goBackUrl).toEqual(fakeReferrer);
- });
-
- it('renders the Go Back link with the project url when referrer is not present', () => {
- fakeReferrer = '';
- spyOnProperty(document, 'referrer').and.returnValue(fakeReferrer);
-
- vm = createComponent(Component, {
- projectUrl: fakeProjectUrl,
- }).$mount();
-
- expect(vm.goBackUrl).toEqual(fakeProjectUrl);
- });
- });
-});
diff --git a/spec/javascripts/ide/components/ide_project_tree_spec.js b/spec/javascripts/ide/components/ide_project_tree_spec.js
deleted file mode 100644
index 657682cb39c..00000000000
--- a/spec/javascripts/ide/components/ide_project_tree_spec.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import Vue from 'vue';
-import ProjectTree from '~/ide/components/ide_project_tree.vue';
-import createComponent from 'spec/helpers/vue_mount_component_helper';
-
-describe('IDE project tree', () => {
- const Component = Vue.extend(ProjectTree);
- let vm;
-
- beforeEach(() => {
- vm = createComponent(Component, {
- project: {
- id: 1,
- name: 'test',
- web_url: gl.TEST_HOST,
- avatar_url: '',
- branches: [],
- },
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders identicon when projct has no avatar', () => {
- expect(vm.$el.querySelector('.identicon')).not.toBeNull();
- });
-
- it('renders avatar image if project has avatar', done => {
- vm.project.avatar_url = gl.TEST_HOST;
-
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.identicon')).toBeNull();
- expect(vm.$el.querySelector('img.avatar')).not.toBeNull();
-
- done();
- });
- });
-});
diff --git a/spec/javascripts/ide/components/ide_repo_tree_spec.js b/spec/javascripts/ide/components/ide_repo_tree_spec.js
deleted file mode 100644
index e0fbc90ca61..00000000000
--- a/spec/javascripts/ide/components/ide_repo_tree_spec.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import Vue from 'vue';
-import ideRepoTree from '~/ide/components/ide_repo_tree.vue';
-import createComponent from '../../helpers/vue_mount_component_helper';
-import { file } from '../helpers';
-
-describe('IdeRepoTree', () => {
- let vm;
- let tree;
-
- beforeEach(() => {
- const IdeRepoTree = Vue.extend(ideRepoTree);
-
- tree = {
- tree: [file()],
- loading: false,
- };
-
- vm = createComponent(IdeRepoTree, {
- tree,
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders a sidebar', () => {
- expect(vm.$el.querySelector('.loading-file')).toBeNull();
- expect(vm.$el.querySelector('.file')).not.toBeNull();
- });
-
- it('renders 3 loading files if tree is loading', done => {
- tree.loading = true;
-
- vm.$nextTick(() => {
- expect(
- vm.$el.querySelectorAll('.multi-file-loading-container').length,
- ).toEqual(3);
-
- done();
- });
- });
-});
diff --git a/spec/javascripts/ide/components/ide_review_spec.js b/spec/javascripts/ide/components/ide_review_spec.js
new file mode 100644
index 00000000000..b9ee22b7c1a
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_review_spec.js
@@ -0,0 +1,69 @@
+import Vue from 'vue';
+import IdeReview from '~/ide/components/ide_review.vue';
+import store from '~/ide/stores';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { trimText } from '../../helpers/vue_component_helper';
+import { resetStore, file } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IDE review mode', () => {
+ const Component = Vue.extend(IdeReview);
+ let vm;
+
+ beforeEach(() => {
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = Object.assign({}, projectData);
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree: [file('fileName')],
+ loading: false,
+ });
+
+ vm = createComponentWithStore(Component, store).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders list of files', () => {
+ expect(vm.$el.textContent).toContain('fileName');
+ });
+
+ describe('merge request', () => {
+ beforeEach(done => {
+ store.state.currentMergeRequestId = '1';
+ store.state.projects.abcproject.mergeRequests['1'] = {
+ iid: 123,
+ web_url: 'testing123',
+ };
+
+ vm.$nextTick(done);
+ });
+
+ it('renders edit dropdown', () => {
+ expect(vm.$el.querySelector('.btn')).not.toBe(null);
+ });
+
+ it('renders merge request link & IID', () => {
+ const link = vm.$el.querySelector('.ide-review-sub-header');
+
+ expect(link.querySelector('a').getAttribute('href')).toBe('testing123');
+ expect(trimText(link.textContent)).toBe('Merge request (!123)');
+ });
+
+ it('changes text to latest changes when viewer is not mrdiff', done => {
+ store.state.viewer = 'diff';
+
+ vm.$nextTick(() => {
+ expect(trimText(vm.$el.querySelector('.ide-review-sub-header').textContent)).toBe(
+ 'Latest changes',
+ );
+
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_side_bar_spec.js b/spec/javascripts/ide/components/ide_side_bar_spec.js
index 699dae1ce2f..20ee20bc1d7 100644
--- a/spec/javascripts/ide/components/ide_side_bar_spec.js
+++ b/spec/javascripts/ide/components/ide_side_bar_spec.js
@@ -1,8 +1,10 @@
import Vue from 'vue';
import store from '~/ide/stores';
import ideSidebar from '~/ide/components/ide_side_bar.vue';
+import { activityBarViews } from '~/ide/constants';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from '../helpers';
+import { projectData } from '../mock_data';
describe('IdeSidebar', () => {
let vm;
@@ -10,6 +12,9 @@ describe('IdeSidebar', () => {
beforeEach(() => {
const Component = Vue.extend(ideSidebar);
+ store.state.currentProjectId = 'abcproject';
+ store.state.projects.abcproject = projectData;
+
vm = createComponentWithStore(Component, store).$mount();
});
@@ -20,23 +25,33 @@ describe('IdeSidebar', () => {
});
it('renders a sidebar', () => {
- expect(
- vm.$el.querySelector('.multi-file-commit-panel-inner'),
- ).not.toBeNull();
+ expect(vm.$el.querySelector('.multi-file-commit-panel-inner')).not.toBeNull();
});
it('renders loading icon component', done => {
vm.$store.state.loading = true;
vm.$nextTick(() => {
- expect(
- vm.$el.querySelector('.multi-file-loading-container'),
- ).not.toBeNull();
- expect(
- vm.$el.querySelectorAll('.multi-file-loading-container').length,
- ).toBe(3);
+ expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
+ expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
done();
});
});
+
+ describe('activityBarComponent', () => {
+ it('renders tree component', () => {
+ expect(vm.$el.querySelector('.ide-file-list')).not.toBeNull();
+ });
+
+ it('renders commit component', done => {
+ vm.$store.state.currentActivityView = activityBarViews.commit;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.multi-file-commit-panel-section')).not.toBeNull();
+
+ done();
+ });
+ });
+ });
});
diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js
index 7bfcfc90572..6f580e1f7af 100644
--- a/spec/javascripts/ide/components/ide_spec.js
+++ b/spec/javascripts/ide/components/ide_spec.js
@@ -4,6 +4,7 @@ import store from '~/ide/stores';
import ide from '~/ide/components/ide.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { file, resetStore } from '../helpers';
+import { projectData } from '../mock_data';
describe('ide component', () => {
let vm;
@@ -11,6 +12,10 @@ describe('ide component', () => {
beforeEach(() => {
const Component = Vue.extend(ide);
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = Object.assign({}, projectData);
+
vm = createComponentWithStore(Component, store, {
emptyStateSvgPath: 'svg',
noChangesStateSvgPath: 'svg',
@@ -24,11 +29,11 @@ describe('ide component', () => {
resetStore(vm.$store);
});
- it('does not render panel right when no files open', () => {
+ it('does not render right right when no files open', () => {
expect(vm.$el.querySelector('.panel-right')).toBeNull();
});
- it('renders panel right when files are open', done => {
+ it('renders right panel when files are open', done => {
vm.$store.state.trees['abcproject/mybranch'] = {
tree: [file()],
};
diff --git a/spec/javascripts/ide/components/ide_tree_list_spec.js b/spec/javascripts/ide/components/ide_tree_list_spec.js
new file mode 100644
index 00000000000..4ecbdb8a55e
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_tree_list_spec.js
@@ -0,0 +1,54 @@
+import Vue from 'vue';
+import IdeTreeList from '~/ide/components/ide_tree_list.vue';
+import store from '~/ide/stores';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { resetStore, file } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IDE tree list', () => {
+ const Component = Vue.extend(IdeTreeList);
+ let vm;
+
+ beforeEach(() => {
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = Object.assign({}, projectData);
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree: [file('fileName')],
+ loading: false,
+ });
+
+ vm = createComponentWithStore(Component, store, {
+ viewerType: 'edit',
+ });
+
+ spyOn(vm, 'updateViewer').and.callThrough();
+
+ vm.$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('updates viewer on mount', () => {
+ expect(vm.updateViewer).toHaveBeenCalledWith('edit');
+ });
+
+ it('renders loading indicator', done => {
+ store.state.trees['abcproject/master'].loading = true;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
+ expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
+
+ done();
+ });
+ });
+
+ it('renders list of files', () => {
+ expect(vm.$el.textContent).toContain('fileName');
+ });
+});
diff --git a/spec/javascripts/ide/components/ide_tree_spec.js b/spec/javascripts/ide/components/ide_tree_spec.js
new file mode 100644
index 00000000000..97a0a2432f1
--- /dev/null
+++ b/spec/javascripts/ide/components/ide_tree_spec.js
@@ -0,0 +1,34 @@
+import Vue from 'vue';
+import IdeTree from '~/ide/components/ide_tree.vue';
+import store from '~/ide/stores';
+import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
+import { resetStore, file } from '../helpers';
+import { projectData } from '../mock_data';
+
+describe('IdeRepoTree', () => {
+ let vm;
+
+ beforeEach(() => {
+ const IdeRepoTree = Vue.extend(IdeTree);
+
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = Object.assign({}, projectData);
+ Vue.set(store.state.trees, 'abcproject/master', {
+ tree: [file('fileName')],
+ loading: false,
+ });
+
+ vm = createComponentWithStore(IdeRepoTree, store).$mount();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+
+ resetStore(vm.$store);
+ });
+
+ it('renders list of files', () => {
+ expect(vm.$el.textContent).toContain('fileName');
+ });
+});
diff --git a/spec/javascripts/ide/components/repo_commit_section_spec.js b/spec/javascripts/ide/components/repo_commit_section_spec.js
index 768f6e99bf1..5e3e00a180b 100644
--- a/spec/javascripts/ide/components/repo_commit_section_spec.js
+++ b/spec/javascripts/ide/components/repo_commit_section_spec.js
@@ -1,9 +1,9 @@
import Vue from 'vue';
import store from '~/ide/stores';
import service from '~/ide/services';
+import router from '~/ide/ide_router';
import repoCommitSection from '~/ide/components/repo_commit_section.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
-import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper';
import { file, resetStore } from '../helpers';
describe('RepoCommitSection', () => {
@@ -12,10 +12,10 @@ describe('RepoCommitSection', () => {
function createComponent() {
const Component = Vue.extend(repoCommitSection);
- vm = createComponentWithStore(Component, store, {
- noChangesStateSvgPath: 'svg',
- committedStateSvgPath: 'commitsvg',
- });
+ store.state.noChangesStateSvgPath = 'svg';
+ store.state.committedStateSvgPath = 'commitsvg';
+
+ vm = createComponentWithStore(Component, store);
vm.$store.state.currentProjectId = 'abcproject';
vm.$store.state.currentBranchId = 'master';
@@ -60,6 +60,8 @@ describe('RepoCommitSection', () => {
}
beforeEach(done => {
+ spyOn(router, 'push');
+
vm = createComponent();
spyOn(service, 'getTreeData').and.returnValue(
@@ -93,61 +95,49 @@ describe('RepoCommitSection', () => {
resetStore(vm.$store);
const Component = Vue.extend(repoCommitSection);
- vm = createComponentWithStore(Component, store, {
- noChangesStateSvgPath: 'nochangessvg',
- committedStateSvgPath: 'svg',
- }).$mount();
+ store.state.noChangesStateSvgPath = 'nochangessvg';
+ store.state.committedStateSvgPath = 'svg';
- expect(
- vm.$el.querySelector('.js-empty-state').textContent.trim(),
- ).toContain('No changes');
- expect(
- vm.$el.querySelector('.js-empty-state img').getAttribute('src'),
- ).toBe('nochangessvg');
+ vm = createComponentWithStore(Component, store).$mount();
+
+ expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes');
+ expect(vm.$el.querySelector('.js-empty-state img').getAttribute('src')).toBe('nochangessvg');
});
});
it('renders a commit section', () => {
- const changedFileElements = [
- ...vm.$el.querySelectorAll('.multi-file-commit-list li'),
- ];
- const submitCommit = vm.$el.querySelector('form .btn');
- const allFiles = vm.$store.state.changedFiles.concat(
- vm.$store.state.stagedFiles,
- );
+ const changedFileElements = [...vm.$el.querySelectorAll('.multi-file-commit-list li')];
+ const allFiles = vm.$store.state.changedFiles.concat(vm.$store.state.stagedFiles);
- expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull();
expect(changedFileElements.length).toEqual(4);
changedFileElements.forEach((changedFile, i) => {
expect(changedFile.textContent.trim()).toContain(allFiles[i].path);
});
-
- expect(submitCommit.disabled).toBeTruthy();
- expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull();
});
it('adds changed files into staged files', done => {
- vm.$el.querySelector('.ide-staged-action-btn').click();
-
- Vue.nextTick(() => {
- expect(
- vm.$el.querySelector('.ide-commit-list-container').textContent,
- ).toContain('No changes');
-
- done();
- });
+ vm.$el.querySelector('.multi-file-discard-btn .btn').click();
+ vm
+ .$nextTick()
+ .then(() => vm.$el.querySelector('.multi-file-discard-btn .btn').click())
+ .then(vm.$nextTick)
+ .then(() => {
+ expect(vm.$el.querySelector('.ide-commit-list-container').textContent).toContain(
+ 'No changes',
+ );
+ })
+ .then(done)
+ .catch(done.fail);
});
it('stages a single file', done => {
vm.$el.querySelector('.multi-file-discard-btn .btn').click();
Vue.nextTick(() => {
- expect(
- vm.$el
- .querySelector('.ide-commit-list-container')
- .querySelectorAll('li').length,
- ).toBe(1);
+ expect(vm.$el.querySelector('.ide-commit-list-container').querySelectorAll('li').length).toBe(
+ 1,
+ );
done();
});
@@ -157,26 +147,10 @@ describe('RepoCommitSection', () => {
vm.$el.querySelectorAll('.multi-file-discard-btn .btn')[1].click();
Vue.nextTick(() => {
- expect(
- vm.$el.querySelector('.ide-commit-list-container').textContent,
- ).not.toContain('file1');
- expect(
- vm.$el
- .querySelector('.ide-commit-list-container')
- .querySelectorAll('li').length,
- ).toBe(1);
-
- done();
- });
- });
-
- it('removes all staged files', done => {
- vm.$el.querySelectorAll('.ide-staged-action-btn')[1].click();
-
- Vue.nextTick(() => {
- expect(
- vm.$el.querySelectorAll('.ide-commit-list-container')[1].textContent,
- ).toContain('No changes');
+ expect(vm.$el.querySelector('.ide-commit-list-container').textContent).not.toContain('file1');
+ expect(vm.$el.querySelector('.ide-commit-list-container').querySelectorAll('li').length).toBe(
+ 1,
+ );
done();
});
@@ -190,75 +164,17 @@ describe('RepoCommitSection', () => {
Vue.nextTick(() => {
expect(
- vm.$el
- .querySelectorAll('.ide-commit-list-container')[1]
- .querySelectorAll('li').length,
+ vm.$el.querySelectorAll('.ide-commit-list-container')[1].querySelectorAll('li').length,
).toBe(1);
done();
});
});
- it('updates commitMessage in store on input', done => {
- const textarea = vm.$el.querySelector('textarea');
-
- textarea.value = 'testing commit message';
-
- textarea.dispatchEvent(new Event('input'));
-
- getSetTimeoutPromise()
- .then(() => {
- expect(vm.$store.state.commit.commitMessage).toBe(
- 'testing commit message',
- );
- })
- .then(done)
- .catch(done.fail);
- });
-
- describe('discard draft button', () => {
- it('hidden when commitMessage is empty', () => {
- expect(
- vm.$el.querySelector('.multi-file-commit-form .btn-default'),
- ).toBeNull();
- });
-
- it('resets commitMessage when clicking discard button', done => {
- vm.$store.state.commit.commitMessage = 'testing commit message';
-
- getSetTimeoutPromise()
- .then(() => {
- vm.$el.querySelector('.multi-file-commit-form .btn-default').click();
- })
- .then(Vue.nextTick)
- .then(() => {
- expect(vm.$store.state.commit.commitMessage).not.toBe(
- 'testing commit message',
- );
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
- describe('when submitting', () => {
- beforeEach(() => {
- spyOn(vm, 'commitChanges');
- });
-
- it('calls commitChanges', done => {
- vm.$store.state.commit.commitMessage = 'testing commit message';
-
- getSetTimeoutPromise()
- .then(() => {
- vm.$el.querySelector('.multi-file-commit-form .btn-success').click();
- })
- .then(Vue.nextTick)
- .then(() => {
- expect(vm.commitChanges).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ describe('mounted', () => {
+ it('opens last opened file', () => {
+ expect(store.state.openFiles.length).toBe(1);
+ expect(store.state.openFiles[0].pending).toBe(true);
});
});
});
diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js
index b06a6c62a1c..360b6d4dc15 100644
--- a/spec/javascripts/ide/components/repo_editor_spec.js
+++ b/spec/javascripts/ide/components/repo_editor_spec.js
@@ -5,6 +5,7 @@ import store from '~/ide/stores';
import repoEditor from '~/ide/components/repo_editor.vue';
import monacoLoader from '~/ide/monaco_loader';
import Editor from '~/ide/lib/editor';
+import { activityBarViews } from '~/ide/constants';
import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import setTimeoutPromise from '../../helpers/set_timeout_promise_helper';
import { file, resetStore } from '../helpers';
@@ -295,4 +296,30 @@ describe('RepoEditor', () => {
});
});
});
+
+ describe('show tabs', () => {
+ it('shows tabs in edit mode', () => {
+ expect(vm.$el.querySelector('.nav-links')).not.toBe(null);
+ });
+
+ it('hides tabs in review mode', done => {
+ vm.$store.state.currentActivityView = activityBarViews.review;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.nav-links')).toBe(null);
+
+ done();
+ });
+ });
+
+ it('hides tabs in commit mode', done => {
+ vm.$store.state.currentActivityView = activityBarViews.commit;
+
+ vm.$nextTick(() => {
+ expect(vm.$el.querySelector('.nav-links')).toBe(null);
+
+ done();
+ });
+ });
+ });
});
diff --git a/spec/javascripts/ide/components/repo_file_spec.js b/spec/javascripts/ide/components/repo_file_spec.js
index 28ff06e1f80..156233653ab 100644
--- a/spec/javascripts/ide/components/repo_file_spec.js
+++ b/spec/javascripts/ide/components/repo_file_spec.js
@@ -73,6 +73,43 @@ describe('RepoFile', () => {
expect(treeChangesEl).not.toBeNull();
expect(treeChangesEl.textContent).toContain('1');
});
+
+ it('renders action dropdown', done => {
+ createComponent({
+ file: {
+ ...file('t4'),
+ type: 'tree',
+ branchId: 'master',
+ projectId: 'project',
+ },
+ level: 0,
+ });
+
+ setTimeout(() => {
+ expect(vm.$el.querySelector('.ide-new-btn')).not.toBeNull();
+
+ done();
+ });
+ });
+
+ it('disables action dropdown', done => {
+ createComponent({
+ file: {
+ ...file('t4'),
+ type: 'tree',
+ branchId: 'master',
+ projectId: 'project',
+ },
+ level: 0,
+ disableActionDropdown: true,
+ });
+
+ setTimeout(() => {
+ expect(vm.$el.querySelector('.ide-new-btn')).toBeNull();
+
+ done();
+ });
+ });
});
describe('locked file', () => {
diff --git a/spec/javascripts/ide/components/repo_tabs_spec.js b/spec/javascripts/ide/components/repo_tabs_spec.js
index cb785ba2cd3..583f71e6121 100644
--- a/spec/javascripts/ide/components/repo_tabs_spec.js
+++ b/spec/javascripts/ide/components/repo_tabs_spec.js
@@ -26,60 +26,10 @@ describe('RepoTabs', () => {
const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')];
expect(tabs.length).toEqual(2);
- expect(tabs[0].classList.contains('active')).toEqual(true);
- expect(tabs[1].classList.contains('active')).toEqual(false);
+ expect(tabs[0].parentNode.classList.contains('active')).toEqual(true);
+ expect(tabs[1].parentNode.classList.contains('active')).toEqual(false);
done();
});
});
-
- describe('updated', () => {
- it('sets showShadow as true when scroll width is larger than width', done => {
- const el = document.createElement('div');
- el.innerHTML = '<div id="test-app"></div>';
- document.body.appendChild(el);
-
- const style = document.createElement('style');
- style.innerText = `
- .multi-file-tabs {
- width: 100px;
- }
-
- .multi-file-tabs .list-unstyled {
- display: flex;
- overflow-x: auto;
- }
- `;
- document.head.appendChild(style);
-
- vm = createComponent(
- RepoTabs,
- {
- files: [],
- viewer: 'editor',
- hasChanges: false,
- activeFile: file('activeFile'),
- hasMergeRequest: false,
- },
- '#test-app',
- );
-
- vm
- .$nextTick()
- .then(() => {
- expect(vm.showShadow).toEqual(false);
-
- vm.files = openedFiles;
- })
- .then(vm.$nextTick)
- .then(() => {
- expect(vm.showShadow).toEqual(true);
-
- style.remove();
- el.remove();
- })
- .then(done)
- .catch(done.fail);
- });
- });
});
diff --git a/spec/javascripts/ide/lib/editor_spec.js b/spec/javascripts/ide/lib/editor_spec.js
index 530bdfa2759..b88a12264ca 100644
--- a/spec/javascripts/ide/lib/editor_spec.js
+++ b/spec/javascripts/ide/lib/editor_spec.js
@@ -74,10 +74,10 @@ describe('Multi-file editor library', () => {
scrollBeyondLastLine: false,
quickSuggestions: false,
occurrencesHighlight: false,
- renderLineHighlight: 'none',
- hideCursorInOverviewRuler: true,
wordWrap: 'on',
renderSideBySide: true,
+ renderLineHighlight: 'all',
+ hideCursorInOverviewRuler: false,
});
});
});
diff --git a/spec/javascripts/ide/mock_data.js b/spec/javascripts/ide/mock_data.js
new file mode 100644
index 00000000000..3c6d75ab5e4
--- /dev/null
+++ b/spec/javascripts/ide/mock_data.js
@@ -0,0 +1,15 @@
+// eslint-disable-next-line import/prefer-default-export
+export const projectData = {
+ id: 1,
+ name: 'abcproject',
+ web_url: '',
+ avatar_url: '',
+ path: '',
+ name_with_namespace: 'namespace/abcproject',
+ branches: {
+ master: {
+ treeId: 'abcproject/master',
+ },
+ },
+ mergeRequests: {},
+};
diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/javascripts/ide/stores/actions/file_spec.js
index 3ee11bd2f03..3ef5a859001 100644
--- a/spec/javascripts/ide/stores/actions/file_spec.js
+++ b/spec/javascripts/ide/stores/actions/file_spec.js
@@ -511,7 +511,10 @@ describe('IDE store file actions', () => {
actions.stageChange,
'path',
store.state,
- [{ type: types.STAGE_CHANGE, payload: 'path' }],
+ [
+ { type: types.STAGE_CHANGE, payload: 'path' },
+ { type: types.SET_LAST_COMMIT_MSG, payload: '' },
+ ],
[],
done,
);
@@ -524,7 +527,10 @@ describe('IDE store file actions', () => {
actions.unstageChange,
'path',
store.state,
- [{ type: types.UNSTAGE_CHANGE, payload: 'path' }],
+ [
+ { type: types.UNSTAGE_CHANGE, payload: 'path' },
+ { type: types.SET_LAST_COMMIT_MSG, payload: '' },
+ ],
[],
done,
);
@@ -589,20 +595,6 @@ describe('IDE store file actions', () => {
.then(done)
.catch(done.fail);
});
-
- it('returns false when passed in file is active & viewer is diff', done => {
- f.active = true;
- store.state.openFiles.push(f);
- store.state.viewer = 'diff';
-
- store
- .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' })
- .then(added => {
- expect(added).toBe(false);
- })
- .then(done)
- .catch(done.fail);
- });
});
describe('removePendingTab', () => {
diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js
index a64af5b941b..062c3497623 100644
--- a/spec/javascripts/ide/stores/actions_spec.js
+++ b/spec/javascripts/ide/stores/actions_spec.js
@@ -2,6 +2,9 @@ import actions, {
stageAllChanges,
unstageAllChanges,
toggleFileFinder,
+ setCurrentBranchId,
+ setEmptyStateSvgs,
+ updateActivityBarView,
updateTempFlagForEntry,
} from '~/ide/stores/actions';
import store from '~/ide/stores';
@@ -306,6 +309,7 @@ describe('Multi-file store actions', () => {
null,
store.state,
[
+ { type: types.SET_LAST_COMMIT_MSG, payload: '' },
{ type: types.STAGE_CHANGE, payload: store.state.changedFiles[0].path },
{ type: types.STAGE_CHANGE, payload: store.state.changedFiles[1].path },
],
@@ -345,6 +349,32 @@ describe('Multi-file store actions', () => {
});
});
+ describe('updateActivityBarView', () => {
+ it('commits UPDATE_ACTIVITY_BAR_VIEW', done => {
+ testAction(
+ updateActivityBarView,
+ 'test',
+ {},
+ [{ type: 'UPDATE_ACTIVITY_BAR_VIEW', payload: 'test' }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('setEmptyStateSvgs', () => {
+ it('commits setEmptyStateSvgs', done => {
+ testAction(
+ setEmptyStateSvgs,
+ 'svg',
+ {},
+ [{ type: 'SET_EMPTY_STATE_SVGS', payload: 'svg' }],
+ [],
+ done,
+ );
+ });
+ });
+
describe('updateTempFlagForEntry', () => {
it('commits UPDATE_TEMP_FLAG', done => {
const f = {
@@ -388,6 +418,19 @@ describe('Multi-file store actions', () => {
});
});
+ describe('setCurrentBranchId', () => {
+ it('commits setCurrentBranchId', done => {
+ testAction(
+ setCurrentBranchId,
+ 'branchId',
+ {},
+ [{ type: 'SET_CURRENT_BRANCH', payload: 'branchId' }],
+ [],
+ done,
+ );
+ });
+ });
+
describe('toggleFileFinder', () => {
it('commits TOGGLE_FILE_FINDER', done => {
testAction(
diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js
index bd834443730..67a848e8edd 100644
--- a/spec/javascripts/ide/stores/getters_spec.js
+++ b/spec/javascripts/ide/stores/getters_spec.js
@@ -37,12 +37,6 @@ describe('IDE store getters', () => {
expect(modifiedFiles.length).toBe(1);
expect(modifiedFiles[0].name).toBe('changed');
});
-
- it('returns angle left when collapsed', () => {
- localState.rightPanelCollapsed = true;
-
- expect(getters.collapseButtonIcon(localState)).toBe('angle-double-left');
- });
});
describe('currentMergeRequest', () => {
diff --git a/spec/javascripts/ide/stores/modules/commit/actions_spec.js b/spec/javascripts/ide/stores/modules/commit/actions_spec.js
index b2b4b85ca42..a2869ff378b 100644
--- a/spec/javascripts/ide/stores/modules/commit/actions_spec.js
+++ b/spec/javascripts/ide/stores/modules/commit/actions_spec.js
@@ -289,21 +289,6 @@ describe('IDE commit module actions', () => {
.then(done)
.catch(done.fail);
});
-
- it('pushes route to new branch if commitAction is new branch', done => {
- store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH;
-
- store
- .dispatch('commit/updateFilesAfterCommit', {
- data,
- branch,
- })
- .then(() => {
- expect(router.push).toHaveBeenCalledWith(`/project/abcproject/blob/master/${f.path}`);
- })
- .then(done)
- .catch(done.fail);
- });
});
describe('commitChanges', () => {
@@ -391,21 +376,6 @@ describe('IDE commit module actions', () => {
.catch(done.fail);
});
- it('pushes router to new route', done => {
- store
- .dispatch('commit/commitChanges')
- .then(() => {
- expect(router.push).toHaveBeenCalledWith(
- `/project/${store.state.currentProjectId}/blob/${
- store.getters['commit/newBranchName']
- }/changed`,
- );
-
- done();
- })
- .catch(done.fail);
- });
-
it('sets last Commit Msg', done => {
store
.dispatch('commit/commitChanges')
diff --git a/spec/javascripts/ide/stores/mutations/file_spec.js b/spec/javascripts/ide/stores/mutations/file_spec.js
index 6fba934810d..e83961fcedc 100644
--- a/spec/javascripts/ide/stores/mutations/file_spec.js
+++ b/spec/javascripts/ide/stores/mutations/file_spec.js
@@ -267,41 +267,23 @@ describe('IDE store file mutations', () => {
it('adds file into openFiles as pending', () => {
mutations.ADD_PENDING_TAB(localState, { file: localFile });
- expect(localState.openFiles.length).toBe(2);
- expect(localState.openFiles[1].pending).toBe(true);
- expect(localState.openFiles[1].key).toBe(`pending-${localFile.key}`);
- });
-
- it('updates open file to pending', () => {
- mutations.ADD_PENDING_TAB(localState, { file: localState.openFiles[0] });
-
expect(localState.openFiles.length).toBe(1);
+ expect(localState.openFiles[0].pending).toBe(true);
+ expect(localState.openFiles[0].key).toBe(`pending-${localFile.key}`);
});
- it('updates pending open file to active', () => {
- localState.openFiles.push({
- ...localFile,
- pending: true,
- });
+ it('only allows 1 open pending file', () => {
+ const newFile = file('test');
+ localState.entries[newFile.path] = newFile;
mutations.ADD_PENDING_TAB(localState, { file: localFile });
- expect(localState.openFiles[1].pending).toBe(true);
- expect(localState.openFiles[1].active).toBe(true);
- });
-
- it('sets all openFiles to not active', () => {
- mutations.ADD_PENDING_TAB(localState, { file: localFile });
+ expect(localState.openFiles.length).toBe(1);
- expect(localState.openFiles.length).toBe(2);
+ mutations.ADD_PENDING_TAB(localState, { file: file('test') });
- localState.openFiles.forEach(f => {
- if (f.pending) {
- expect(f.active).toBe(true);
- } else {
- expect(f.active).toBe(false);
- }
- });
+ expect(localState.openFiles.length).toBe(1);
+ expect(localState.openFiles[0].name).toBe('test');
});
});
diff --git a/spec/javascripts/ide/stores/mutations_spec.js b/spec/javascripts/ide/stores/mutations_spec.js
index 61efb6372c9..972713c5ad2 100644
--- a/spec/javascripts/ide/stores/mutations_spec.js
+++ b/spec/javascripts/ide/stores/mutations_spec.js
@@ -87,6 +87,28 @@ describe('Multi-file store mutations', () => {
});
});
+ describe('UPDATE_ACTIVITY_BAR_VIEW', () => {
+ it('updates currentActivityBar', () => {
+ mutations.UPDATE_ACTIVITY_BAR_VIEW(localState, 'test');
+
+ expect(localState.currentActivityView).toBe('test');
+ });
+ });
+
+ describe('SET_EMPTY_STATE_SVGS', () => {
+ it('updates empty state SVGs', () => {
+ mutations.SET_EMPTY_STATE_SVGS(localState, {
+ emptyStateSvgPath: 'emptyState',
+ noChangesStateSvgPath: 'noChanges',
+ committedStateSvgPath: 'commited',
+ });
+
+ expect(localState.emptyStateSvgPath).toBe('emptyState');
+ expect(localState.noChangesStateSvgPath).toBe('noChanges');
+ expect(localState.committedStateSvgPath).toBe('commited');
+ });
+ });
+
describe('UPDATE_TEMP_FLAG', () => {
beforeEach(() => {
localState.entries.test = {
diff --git a/spec/javascripts/pipelines/graph/action_component_spec.js b/spec/javascripts/pipelines/graph/action_component_spec.js
index 3de10392472..d646bef96f5 100644
--- a/spec/javascripts/pipelines/graph/action_component_spec.js
+++ b/spec/javascripts/pipelines/graph/action_component_spec.js
@@ -22,7 +22,7 @@ describe('pipeline graph action component', () => {
});
it('should emit an event with the provided link', () => {
- eventHub.$on('graphAction', link => {
+ eventHub.$on('postAction', link => {
expect(link).toEqual('foo');
});
});
diff --git a/spec/javascripts/pipelines/mock_data.js b/spec/javascripts/pipelines/mock_data.js
index 59092e0f041..a5a200973d7 100644
--- a/spec/javascripts/pipelines/mock_data.js
+++ b/spec/javascripts/pipelines/mock_data.js
@@ -321,6 +321,103 @@ export const pipelineWithStages = {
};
export const stageReply = {
- html:
- '\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="karma - failed \u0026lt;br\u0026gt; (script failure)" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62402048"\u003e\u003cspan class="ci-status-icon ci-status-icon-failed"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_failed"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003ekarma\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62402048/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="codequality - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398081"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003ecodequality\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398081/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="db:check-schema-pg - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398066"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edb:check-schema-pg\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398066/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="db:migrate:reset-mysql - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398065"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edb:migrate:reset-mysql\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398065/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="db:migrate:reset-pg - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398064"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edb:migrate:reset-pg\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398064/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="db:rollback-mysql - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398070"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edb:rollback-mysql\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398070/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="db:rollback-pg - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398069"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edb:rollback-pg\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398069/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="dependency_scanning - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398083"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edependency_scanning\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398083/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="docs lint - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398061"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edocs lint\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398061/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="downtime_check - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398062"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003edowntime_check\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398062/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="ee_compat_check - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398063"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003eee_compat_check\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398063/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="gitlab:assets:compile - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398075"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003egitlab:assets:compile\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398075/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="gitlab:setup-mysql - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398073"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003egitlab:setup-mysql\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398073/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="gitlab:setup-pg - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398071"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003egitlab:setup-pg\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398071/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="gitlab_git_test - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398086"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003egitlab_git_test\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398086/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="migration:path-mysql - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398068"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003emigration:path-mysql\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398068/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="migration:path-pg - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398067"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003emigration:path-pg\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398067/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="qa:internal - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398084"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003eqa:internal\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398084/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="qa:selectors - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398085"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003eqa:selectors\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398085/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 0 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398020"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 0 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398020/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 1 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398022"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 1 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398022/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 10 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398033"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 10 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398033/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 11 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398034"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 11 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398034/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 12 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398035"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 12 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398035/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 13 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398036"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 13 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398036/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 14 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398037"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 14 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398037/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 15 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398038"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 15 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398038/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 16 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398039"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 16 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398039/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 17 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398040"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 17 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398040/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 18 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398041"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 18 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398041/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 19 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398042"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 19 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398042/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 2 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398024"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 2 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398024/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 20 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398043"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 20 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398043/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 21 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398044"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 21 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398044/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 22 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398046"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 22 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398046/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 23 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398047"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 23 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398047/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 24 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398048"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 24 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398048/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 25 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398049"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 25 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398049/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 26 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398050"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 26 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398050/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 27 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398051"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 27 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398051/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 3 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398025"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 3 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398025/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 4 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398027"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 4 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398027/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 5 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398028"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 5 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398028/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 6 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398029"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 6 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398029/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 7 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398030"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 7 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398030/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 8 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398031"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 8 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398031/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-mysql 9 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398032"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-mysql 9 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398032/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 0 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397981"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 0 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397981/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 1 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397985"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 1 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397985/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 10 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398000"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 10 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398000/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 11 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398001"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 11 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398001/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 12 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398002"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 12 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398002/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 13 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398003"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 13 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398003/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 14 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398004"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 14 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398004/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 15 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398006"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 15 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398006/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 16 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398007"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 16 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398007/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 17 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398008"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 17 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398008/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 18 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398009"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 18 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398009/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 19 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398010"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 19 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398010/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 2 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397986"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 2 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397986/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 20 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398012"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 20 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398012/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 21 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398013"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 21 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398013/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 22 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398014"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 22 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398014/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 23 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398015"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 23 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398015/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 24 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398016"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 24 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398016/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 25 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398017"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 25 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398017/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 26 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398018"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 26 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398018/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 27 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398019"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 27 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398019/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 3 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397988"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 3 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397988/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 4 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397989"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 4 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397989/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 5 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397991"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 5 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397991/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 6 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397993"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 6 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397993/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 7 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397994"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 7 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397994/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 8 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397995"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 8 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397995/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="rspec-pg 9 28 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62397996"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003erspec-pg 9 28\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62397996/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="sast - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398082"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003esast\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398082/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="spinach-mysql 0 2 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398058"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003espinach-mysql 0 2\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398058/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="spinach-mysql 1 2 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398059"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003espinach-mysql 1 2\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398059/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="spinach-pg 0 2 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398053"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003espinach-pg 0 2\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398053/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="spinach-pg 1 2 - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398056"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003espinach-pg 1 2\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398056/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n\u003cli\u003e\n\u003ca class="mini-pipeline-graph-dropdown-item" data-toggle="tooltip" data-title="static-analysis - passed" data-html="true" data-container="body" href="/gitlab-org/gitlab-ce/-/jobs/62398060"\u003e\u003cspan class="ci-status-icon ci-status-icon-success"\u003e\u003csvg\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#status_success"\u003e\u003c/use\u003e\u003c/svg\u003e\u003c/span\u003e\n\u003cspan class="ci-build-text"\u003estatic-analysis\u003c/span\u003e\n\u003c/a\u003e\u003ca class="ci-action-icon-wrapper js-ci-action-icon" data-toggle="tooltip" data-title="Retry" data-container="body" rel="nofollow" data-method="post" href="/gitlab-org/gitlab-ce/-/jobs/62398060/retry"\u003e\u003csvg class=" icon-action-retry"\u003e\u003cuse xlink:href="https://gitlab.com/assets/icons-fe86f87a3d244c952cc0ec8d7f88c5effefcbe454d751d8449d4a1a32aaaf9a0.svg#retry"\u003e\u003c/use\u003e\u003c/svg\u003e\n\u003c/a\u003e\n\u003c/li\u003e\n',
+ name: 'deploy',
+ title: 'deploy: running',
+ latest_statuses: [
+ {
+ id: 928,
+ name: 'stop staging',
+ started: false,
+ build_path: '/twitter/flight/-/jobs/928',
+ cancel_path: '/twitter/flight/-/jobs/928/cancel',
+ playable: false,
+ created_at: '2018-04-04T20:02:02.728Z',
+ updated_at: '2018-04-04T20:02:02.766Z',
+ status: {
+ icon: 'status_pending',
+ text: 'pending',
+ label: 'pending',
+ group: 'pending',
+ tooltip: 'pending',
+ has_details: true,
+ details_path: '/twitter/flight/-/jobs/928',
+ favicon:
+ '/assets/ci_favicons/dev/favicon_status_pending-db32e1faf94b9f89530ac519790920d1f18ea8f6af6cd2e0a26cd6840cacf101.ico',
+ action: {
+ icon: 'cancel',
+ title: 'Cancel',
+ path: '/twitter/flight/-/jobs/928/cancel',
+ method: 'post',
+ },
+ },
+ },
+ {
+ id: 926,
+ name: 'production',
+ started: false,
+ build_path: '/twitter/flight/-/jobs/926',
+ retry_path: '/twitter/flight/-/jobs/926/retry',
+ play_path: '/twitter/flight/-/jobs/926/play',
+ playable: true,
+ created_at: '2018-04-04T20:00:57.202Z',
+ updated_at: '2018-04-04T20:11:13.110Z',
+ status: {
+ icon: 'status_canceled',
+ text: 'canceled',
+ label: 'manual play action',
+ group: 'canceled',
+ tooltip: 'canceled',
+ has_details: true,
+ details_path: '/twitter/flight/-/jobs/926',
+ favicon:
+ '/assets/ci_favicons/dev/favicon_status_canceled-5491840b9b6feafba0bc599cbd49ee9580321dc809683856cf1b0d51532b1af6.ico',
+ action: {
+ icon: 'play',
+ title: 'Play',
+ path: '/twitter/flight/-/jobs/926/play',
+ method: 'post',
+ },
+ },
+ },
+ {
+ id: 217,
+ name: 'staging',
+ started: '2018-03-07T08:41:46.234Z',
+ build_path: '/twitter/flight/-/jobs/217',
+ retry_path: '/twitter/flight/-/jobs/217/retry',
+ playable: false,
+ created_at: '2018-03-07T14:41:58.093Z',
+ updated_at: '2018-03-07T14:41:58.093Z',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ tooltip: 'passed',
+ has_details: true,
+ details_path: '/twitter/flight/-/jobs/217',
+ favicon:
+ '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico',
+ action: {
+ icon: 'retry',
+ title: 'Retry',
+ path: '/twitter/flight/-/jobs/217/retry',
+ method: 'post',
+ },
+ },
+ },
+ ],
+ status: {
+ icon: 'status_running',
+ text: 'running',
+ label: 'running',
+ group: 'running',
+ tooltip: 'running',
+ has_details: true,
+ details_path: '/twitter/flight/pipelines/13#deploy',
+ favicon:
+ '/assets/ci_favicons/dev/favicon_status_running-c3ad2fc53ea6079c174e5b6c1351ff349e99ec3af5a5622fb77b0fe53ea279c1.ico',
+ },
+ path: '/twitter/flight/pipelines/13#deploy',
+ dropdown_path: '/twitter/flight/pipelines/13/stage.json?stage=deploy',
};
diff --git a/spec/javascripts/pipelines/stage_spec.js b/spec/javascripts/pipelines/stage_spec.js
index be1632e7206..75156e7bdfd 100644
--- a/spec/javascripts/pipelines/stage_spec.js
+++ b/spec/javascripts/pipelines/stage_spec.js
@@ -4,6 +4,7 @@ import axios from '~/lib/utils/axios_utils';
import stage from '~/pipelines/components/stage.vue';
import eventHub from '~/pipelines/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { stageReply } from './mock_data';
describe('Pipelines stage component', () => {
let StageComponent;
@@ -41,7 +42,7 @@ describe('Pipelines stage component', () => {
describe('with successfull request', () => {
beforeEach(() => {
- mock.onGet('path.json').reply(200, { html: 'foo' });
+ mock.onGet('path.json').reply(200, stageReply);
});
it('should render the received data and emit `clickedDropdown` event', done => {
@@ -51,7 +52,7 @@ describe('Pipelines stage component', () => {
setTimeout(() => {
expect(
component.$el.querySelector('.js-builds-dropdown-container ul').textContent.trim(),
- ).toEqual('foo');
+ ).toContain(stageReply.latest_statuses[0].name);
expect(eventHub.$emit).toHaveBeenCalledWith('clickedDropdown');
done();
}, 0);
@@ -74,7 +75,9 @@ describe('Pipelines stage component', () => {
describe('update endpoint correctly', () => {
beforeEach(() => {
- mock.onGet('bar.json').reply(200, { html: 'this is the updated content' });
+ const copyStage = Object.assign({}, stageReply);
+ copyStage.latest_statuses[0].name = 'this is the updated content';
+ mock.onGet('bar.json').reply(200, copyStage);
});
it('should update the stage to request the new endpoint provided', done => {
@@ -93,7 +96,7 @@ describe('Pipelines stage component', () => {
setTimeout(() => {
expect(
component.$el.querySelector('.js-builds-dropdown-container ul').textContent.trim(),
- ).toEqual('this is the updated content');
+ ).toContain('this is the updated content');
done();
});
});
diff --git a/spec/javascripts/projects_dropdown/components/app_spec.js b/spec/javascripts/projects_dropdown/components/app_spec.js
index 2054fef790b..38b31c3d727 100644
--- a/spec/javascripts/projects_dropdown/components/app_spec.js
+++ b/spec/javascripts/projects_dropdown/components/app_spec.js
@@ -23,17 +23,18 @@ const createComponent = () => {
});
};
-const returnServicePromise = (data, failed) => new Promise((resolve, reject) => {
- if (failed) {
- reject(data);
- } else {
- resolve({
- json() {
- return data;
- },
- });
- }
-});
+const returnServicePromise = (data, failed) =>
+ new Promise((resolve, reject) => {
+ if (failed) {
+ reject(data);
+ } else {
+ resolve({
+ json() {
+ return data;
+ },
+ });
+ }
+ });
describe('AppComponent', () => {
describe('computed', () => {
@@ -185,7 +186,7 @@ describe('AppComponent', () => {
describe('fetchSearchedProjects', () => {
const searchQuery = 'test';
- it('should perform search with provided search query', (done) => {
+ it('should perform search with provided search query', done => {
const mockData = [mockRawProject];
spyOn(vm, 'toggleLoader');
spyOn(vm, 'toggleSearchProjectsList');
@@ -203,7 +204,7 @@ describe('AppComponent', () => {
}, 0);
});
- it('should update props for showing search failure', (done) => {
+ it('should update props for showing search failure', done => {
spyOn(vm, 'toggleSearchProjectsList');
spyOn(vm.service, 'getSearchedProjects').and.returnValue(returnServicePromise({}, true));
@@ -219,7 +220,7 @@ describe('AppComponent', () => {
});
describe('logCurrentProjectAccess', () => {
- it('should log current project access via service', (done) => {
+ it('should log current project access via service', done => {
spyOn(vm.service, 'logProjectAccess');
vm.currentProject = mockProject;
@@ -257,7 +258,7 @@ describe('AppComponent', () => {
});
describe('created', () => {
- it('should bind event listeners on eventHub', (done) => {
+ it('should bind event listeners on eventHub', done => {
spyOn(eventHub, '$on');
createComponent().$mount();
@@ -273,7 +274,7 @@ describe('AppComponent', () => {
});
describe('beforeDestroy', () => {
- it('should unbind event listeners on eventHub', (done) => {
+ it('should unbind event listeners on eventHub', done => {
const vm = createComponent();
spyOn(eventHub, '$off');
@@ -305,7 +306,7 @@ describe('AppComponent', () => {
expect(vm.$el.querySelector('.search-input-container')).toBeDefined();
});
- it('should render loading animation', (done) => {
+ it('should render loading animation', done => {
vm.toggleLoader(true);
Vue.nextTick(() => {
const loadingEl = vm.$el.querySelector('.loading-animation');
@@ -317,7 +318,7 @@ describe('AppComponent', () => {
});
});
- it('should render frequent projects list header', (done) => {
+ it('should render frequent projects list header', done => {
vm.toggleFrequentProjectsList(true);
Vue.nextTick(() => {
const sectionHeaderEl = vm.$el.querySelector('.section-header');
@@ -328,7 +329,7 @@ describe('AppComponent', () => {
});
});
- it('should render frequent projects list', (done) => {
+ it('should render frequent projects list', done => {
vm.toggleFrequentProjectsList(true);
Vue.nextTick(() => {
expect(vm.$el.querySelector('.projects-list-frequent-container')).toBeDefined();
@@ -336,7 +337,7 @@ describe('AppComponent', () => {
});
});
- it('should render searched projects list', (done) => {
+ it('should render searched projects list', done => {
vm.toggleSearchProjectsList(true);
Vue.nextTick(() => {
expect(vm.$el.querySelector('.section-header')).toBe(null);
diff --git a/spec/lib/backup/repository_spec.rb b/spec/lib/backup/repository_spec.rb
index b3777be312b..b1ea9c0b622 100644
--- a/spec/lib/backup/repository_spec.rb
+++ b/spec/lib/backup/repository_spec.rb
@@ -2,7 +2,7 @@ require 'spec_helper'
describe Backup::Repository do
let(:progress) { StringIO.new }
- let!(:project) { create(:project) }
+ let!(:project) { create(:project, :wiki_repo) }
before do
allow(progress).to receive(:puts)
@@ -102,7 +102,7 @@ describe Backup::Repository do
it 'invalidates the emptiness cache' do
expect(wiki.repository).to receive(:expire_emptiness_caches).once
- wiki.empty?
+ described_class.new.send(:empty_repo?, wiki)
end
context 'wiki repo has content' do
diff --git a/spec/lib/gitlab/ci/trace/chunked_io_spec.rb b/spec/lib/gitlab/ci/trace/chunked_io_spec.rb
new file mode 100644
index 00000000000..6259b952add
--- /dev/null
+++ b/spec/lib/gitlab/ci/trace/chunked_io_spec.rb
@@ -0,0 +1,383 @@
+require 'spec_helper'
+
+describe Gitlab::Ci::Trace::ChunkedIO, :clean_gitlab_redis_cache do
+ include ChunkedIOHelpers
+
+ set(:build) { create(:ci_build, :running) }
+ let(:chunked_io) { described_class.new(build) }
+
+ before do
+ stub_feature_flags(ci_enable_live_trace: true)
+ end
+
+ context "#initialize" do
+ context 'when a chunk exists' do
+ before do
+ build.trace.set('ABC')
+ end
+
+ it { expect(chunked_io.size).to eq(3) }
+ end
+
+ context 'when two chunks exist' do
+ before do
+ stub_buffer_size(4)
+ build.trace.set('ABCDEF')
+ end
+
+ it { expect(chunked_io.size).to eq(6) }
+ end
+
+ context 'when no chunks exists' do
+ it { expect(chunked_io.size).to eq(0) }
+ end
+ end
+
+ context "#seek" do
+ subject { chunked_io.seek(pos, where) }
+
+ before do
+ build.trace.set(sample_trace_raw)
+ end
+
+ context 'when moves pos to end of the file' do
+ let(:pos) { 0 }
+ let(:where) { IO::SEEK_END }
+
+ it { is_expected.to eq(sample_trace_raw.bytesize) }
+ end
+
+ context 'when moves pos to middle of the file' do
+ let(:pos) { sample_trace_raw.bytesize / 2 }
+ let(:where) { IO::SEEK_SET }
+
+ it { is_expected.to eq(pos) }
+ end
+
+ context 'when moves pos around' do
+ it 'matches the result' do
+ expect(chunked_io.seek(0)).to eq(0)
+ expect(chunked_io.seek(100, IO::SEEK_CUR)).to eq(100)
+ expect { chunked_io.seek(sample_trace_raw.bytesize + 1, IO::SEEK_CUR) }
+ .to raise_error('new position is outside of file')
+ end
+ end
+ end
+
+ context "#eof?" do
+ subject { chunked_io.eof? }
+
+ before do
+ build.trace.set(sample_trace_raw)
+ end
+
+ context 'when current pos is at end of the file' do
+ before do
+ chunked_io.seek(sample_trace_raw.bytesize, IO::SEEK_SET)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when current pos is not at end of the file' do
+ before do
+ chunked_io.seek(0, IO::SEEK_SET)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ context "#each_line" do
+ let(:string_io) { StringIO.new(sample_trace_raw) }
+
+ context 'when buffer size is smaller than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize / 2)
+ build.trace.set(sample_trace_raw)
+ end
+
+ it 'yields lines' do
+ expect { |b| chunked_io.each_line(&b) }
+ .to yield_successive_args(*string_io.each_line.to_a)
+ end
+ end
+
+ context 'when buffer size is larger than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize * 2)
+ build.trace.set(sample_trace_raw)
+ end
+
+ it 'calls get_chunk only once' do
+ expect_any_instance_of(Gitlab::Ci::Trace::ChunkedIO)
+ .to receive(:current_chunk).once.and_call_original
+
+ chunked_io.each_line { |line| }
+ end
+ end
+ end
+
+ context "#read" do
+ subject { chunked_io.read(length) }
+
+ context 'when read the whole size' do
+ let(:length) { nil }
+
+ context 'when buffer size is smaller than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize / 2)
+ build.trace.set(sample_trace_raw)
+ end
+
+ it { is_expected.to eq(sample_trace_raw) }
+ end
+
+ context 'when buffer size is larger than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize * 2)
+ build.trace.set(sample_trace_raw)
+ end
+
+ it { is_expected.to eq(sample_trace_raw) }
+ end
+ end
+
+ context 'when read only first 100 bytes' do
+ let(:length) { 100 }
+
+ context 'when buffer size is smaller than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize / 2)
+ build.trace.set(sample_trace_raw)
+ end
+
+ it 'reads a trace' do
+ is_expected.to eq(sample_trace_raw.byteslice(0, length))
+ end
+ end
+
+ context 'when buffer size is larger than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize * 2)
+ build.trace.set(sample_trace_raw)
+ end
+
+ it 'reads a trace' do
+ is_expected.to eq(sample_trace_raw.byteslice(0, length))
+ end
+ end
+ end
+
+ context 'when tries to read oversize' do
+ let(:length) { sample_trace_raw.bytesize + 1000 }
+
+ context 'when buffer size is smaller than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize / 2)
+ build.trace.set(sample_trace_raw)
+ end
+
+ it 'reads a trace' do
+ is_expected.to eq(sample_trace_raw)
+ end
+ end
+
+ context 'when buffer size is larger than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize * 2)
+ build.trace.set(sample_trace_raw)
+ end
+
+ it 'reads a trace' do
+ is_expected.to eq(sample_trace_raw)
+ end
+ end
+ end
+
+ context 'when tries to read 0 bytes' do
+ let(:length) { 0 }
+
+ context 'when buffer size is smaller than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize / 2)
+ build.trace.set(sample_trace_raw)
+ end
+
+ it 'reads a trace' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when buffer size is larger than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize * 2)
+ build.trace.set(sample_trace_raw)
+ end
+
+ it 'reads a trace' do
+ is_expected.to be_empty
+ end
+ end
+ end
+ end
+
+ context "#readline" do
+ subject { chunked_io.readline }
+
+ let(:string_io) { StringIO.new(sample_trace_raw) }
+
+ shared_examples 'all line matching' do
+ it do
+ (0...sample_trace_raw.lines.count).each do
+ expect(chunked_io.readline).to eq(string_io.readline)
+ end
+ end
+ end
+
+ context 'when buffer size is smaller than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize / 2)
+ build.trace.set(sample_trace_raw)
+ end
+
+ it_behaves_like 'all line matching'
+ end
+
+ context 'when buffer size is larger than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize * 2)
+ build.trace.set(sample_trace_raw)
+ end
+
+ it_behaves_like 'all line matching'
+ end
+
+ context 'when pos is at middle of the file' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize / 2)
+ build.trace.set(sample_trace_raw)
+
+ chunked_io.seek(chunked_io.size / 2)
+ string_io.seek(string_io.size / 2)
+ end
+
+ it 'reads from pos' do
+ expect(chunked_io.readline).to eq(string_io.readline)
+ end
+ end
+ end
+
+ context "#write" do
+ subject { chunked_io.write(data) }
+
+ let(:data) { sample_trace_raw }
+
+ context 'when data does not exist' do
+ shared_examples 'writes a trace' do
+ it do
+ is_expected.to eq(data.bytesize)
+
+ chunked_io.seek(0, IO::SEEK_SET)
+ expect(chunked_io.read).to eq(data)
+ end
+ end
+
+ context 'when buffer size is smaller than file size' do
+ before do
+ stub_buffer_size(data.bytesize / 2)
+ end
+
+ it_behaves_like 'writes a trace'
+ end
+
+ context 'when buffer size is larger than file size' do
+ before do
+ stub_buffer_size(data.bytesize * 2)
+ end
+
+ it_behaves_like 'writes a trace'
+ end
+ end
+
+ context 'when data already exists' do
+ let(:exist_data) { 'exist data' }
+
+ shared_examples 'appends a trace' do
+ it do
+ chunked_io.seek(0, IO::SEEK_END)
+ is_expected.to eq(data.bytesize)
+
+ chunked_io.seek(0, IO::SEEK_SET)
+ expect(chunked_io.read).to eq(exist_data + data)
+ end
+ end
+
+ context 'when buffer size is smaller than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize / 2)
+ build.trace.set(exist_data)
+ end
+
+ it_behaves_like 'appends a trace'
+ end
+
+ context 'when buffer size is larger than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize * 2)
+ build.trace.set(exist_data)
+ end
+
+ it_behaves_like 'appends a trace'
+ end
+ end
+ end
+
+ context "#truncate" do
+ let(:offset) { 10 }
+
+ context 'when data does not exist' do
+ shared_examples 'truncates a trace' do
+ it do
+ chunked_io.truncate(offset)
+
+ chunked_io.seek(0, IO::SEEK_SET)
+ expect(chunked_io.read).to eq(sample_trace_raw.byteslice(0, offset))
+ end
+ end
+
+ context 'when buffer size is smaller than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize / 2)
+ build.trace.set(sample_trace_raw)
+ end
+
+ it_behaves_like 'truncates a trace'
+ end
+
+ context 'when buffer size is larger than file size' do
+ before do
+ stub_buffer_size(sample_trace_raw.bytesize * 2)
+ build.trace.set(sample_trace_raw)
+ end
+
+ it_behaves_like 'truncates a trace'
+ end
+ end
+ end
+
+ context "#destroy!" do
+ subject { chunked_io.destroy! }
+
+ before do
+ build.trace.set(sample_trace_raw)
+ end
+
+ it 'deletes' do
+ expect { subject }.to change { chunked_io.size }
+ .from(sample_trace_raw.bytesize).to(0)
+
+ expect(Ci::BuildTraceChunk.where(build: build).count).to eq(0)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/trace/stream_spec.rb b/spec/lib/gitlab/ci/trace/stream_spec.rb
index e5555546fa8..4f49958dd33 100644
--- a/spec/lib/gitlab/ci/trace/stream_spec.rb
+++ b/spec/lib/gitlab/ci/trace/stream_spec.rb
@@ -1,6 +1,12 @@
require 'spec_helper'
-describe Gitlab::Ci::Trace::Stream do
+describe Gitlab::Ci::Trace::Stream, :clean_gitlab_redis_cache do
+ set(:build) { create(:ci_build, :running) }
+
+ before do
+ stub_feature_flags(ci_enable_live_trace: true)
+ end
+
describe 'delegates' do
subject { described_class.new { nil } }
@@ -11,337 +17,470 @@ describe Gitlab::Ci::Trace::Stream do
it { is_expected.to delegate_method(:path).to(:stream) }
it { is_expected.to delegate_method(:truncate).to(:stream) }
it { is_expected.to delegate_method(:valid?).to(:stream).as(:present?) }
- it { is_expected.to delegate_method(:file?).to(:path).as(:present?) }
end
describe '#limit' do
- let(:stream) do
- described_class.new do
- StringIO.new((1..8).to_a.join("\n"))
+ shared_examples_for 'limits' do
+ it 'if size is larger we start from beginning' do
+ stream.limit(20)
+
+ expect(stream.tell).to eq(0)
end
- end
- it 'if size is larger we start from beginning' do
- stream.limit(20)
+ it 'if size is smaller we start from the end' do
+ stream.limit(2)
- expect(stream.tell).to eq(0)
- end
+ expect(stream.raw).to eq("8")
+ end
- it 'if size is smaller we start from the end' do
- stream.limit(2)
+ context 'when the trace contains ANSI sequence and Unicode' do
+ let(:stream) do
+ described_class.new do
+ File.open(expand_fixture_path('trace/ansi-sequence-and-unicode'))
+ end
+ end
- expect(stream.raw).to eq("8")
- end
+ it 'forwards to the next linefeed, case 1' do
+ stream.limit(7)
- context 'when the trace contains ANSI sequence and Unicode' do
- let(:stream) do
- described_class.new do
- File.open(expand_fixture_path('trace/ansi-sequence-and-unicode'))
+ result = stream.raw
+
+ expect(result).to eq('')
+ expect(result.encoding).to eq(Encoding.default_external)
end
- end
- it 'forwards to the next linefeed, case 1' do
- stream.limit(7)
+ it 'forwards to the next linefeed, case 2' do
+ stream.limit(29)
- result = stream.raw
+ result = stream.raw
- expect(result).to eq('')
- expect(result.encoding).to eq(Encoding.default_external)
- end
+ expect(result).to eq("\e[01;32m許功蓋\e[0m\n")
+ expect(result.encoding).to eq(Encoding.default_external)
+ end
- it 'forwards to the next linefeed, case 2' do
- stream.limit(29)
+ # See https://gitlab.com/gitlab-org/gitlab-ce/issues/30796
+ it 'reads in binary, output as Encoding.default_external' do
+ stream.limit(52)
- result = stream.raw
+ result = stream.html
- expect(result).to eq("\e[01;32m許功蓋\e[0m\n")
- expect(result.encoding).to eq(Encoding.default_external)
+ expect(result).to eq("ヾ(´༎ຶД༎ຶ`)ノ<br><span class=\"term-fg-green\">許功蓋</span><br>")
+ expect(result.encoding).to eq(Encoding.default_external)
+ end
end
+ end
- # See https://gitlab.com/gitlab-org/gitlab-ce/issues/30796
- it 'reads in binary, output as Encoding.default_external' do
- stream.limit(52)
+ context 'when stream is StringIO' do
+ let(:stream) do
+ described_class.new do
+ StringIO.new((1..8).to_a.join("\n"))
+ end
+ end
- result = stream.html
+ it_behaves_like 'limits'
+ end
- expect(result).to eq("ヾ(´༎ຶД༎ຶ`)ノ<br><span class=\"term-fg-green\">許功蓋</span><br>")
- expect(result.encoding).to eq(Encoding.default_external)
+ context 'when stream is ChunkedIO' do
+ let(:stream) do
+ described_class.new do
+ Gitlab::Ci::Trace::ChunkedIO.new(build).tap do |chunked_io|
+ chunked_io.write((1..8).to_a.join("\n"))
+ chunked_io.seek(0, IO::SEEK_SET)
+ end
+ end
end
+
+ it_behaves_like 'limits'
end
end
describe '#append' do
- let(:tempfile) { Tempfile.new }
+ shared_examples_for 'appends' do
+ it "truncates and append content" do
+ stream.append("89", 4)
+ stream.seek(0)
- let(:stream) do
- described_class.new do
- tempfile.write("12345678")
- tempfile.rewind
- tempfile
+ expect(stream.size).to eq(6)
+ expect(stream.raw).to eq("123489")
end
- end
- after do
- tempfile.unlink
- end
+ it 'appends in binary mode' do
+ '😺'.force_encoding('ASCII-8BIT').each_char.with_index do |byte, offset|
+ stream.append(byte, offset)
+ end
- it "truncates and append content" do
- stream.append("89", 4)
- stream.seek(0)
+ stream.seek(0)
- expect(stream.size).to eq(6)
- expect(stream.raw).to eq("123489")
+ expect(stream.size).to eq(4)
+ expect(stream.raw).to eq('😺')
+ end
end
- it 'appends in binary mode' do
- '😺'.force_encoding('ASCII-8BIT').each_char.with_index do |byte, offset|
- stream.append(byte, offset)
+ context 'when stream is Tempfile' do
+ let(:tempfile) { Tempfile.new }
+
+ let(:stream) do
+ described_class.new do
+ tempfile.write("12345678")
+ tempfile.rewind
+ tempfile
+ end
+ end
+
+ after do
+ tempfile.unlink
end
- stream.seek(0)
+ it_behaves_like 'appends'
+ end
- expect(stream.size).to eq(4)
- expect(stream.raw).to eq('😺')
+ context 'when stream is ChunkedIO' do
+ let(:stream) do
+ described_class.new do
+ Gitlab::Ci::Trace::ChunkedIO.new(build).tap do |chunked_io|
+ chunked_io.write('12345678')
+ chunked_io.seek(0, IO::SEEK_SET)
+ end
+ end
+ end
+
+ it_behaves_like 'appends'
end
end
describe '#set' do
- let(:stream) do
- described_class.new do
- StringIO.new("12345678")
+ shared_examples_for 'sets' do
+ before do
+ stream.set("8901")
+ end
+
+ it "overwrite content" do
+ stream.seek(0)
+
+ expect(stream.size).to eq(4)
+ expect(stream.raw).to eq("8901")
end
end
- before do
- stream.set("8901")
+ context 'when stream is StringIO' do
+ let(:stream) do
+ described_class.new do
+ StringIO.new("12345678")
+ end
+ end
+
+ it_behaves_like 'sets'
end
- it "overwrite content" do
- stream.seek(0)
+ context 'when stream is ChunkedIO' do
+ let(:stream) do
+ described_class.new do
+ Gitlab::Ci::Trace::ChunkedIO.new(build).tap do |chunked_io|
+ chunked_io.write('12345678')
+ chunked_io.seek(0, IO::SEEK_SET)
+ end
+ end
+ end
- expect(stream.size).to eq(4)
- expect(stream.raw).to eq("8901")
+ it_behaves_like 'sets'
end
end
describe '#raw' do
- let(:path) { __FILE__ }
- let(:lines) { File.readlines(path) }
- let(:stream) do
- described_class.new do
- File.open(path)
+ shared_examples_for 'sets' do
+ it 'returns all contents if last_lines is not specified' do
+ result = stream.raw
+
+ expect(result).to eq(lines.join)
+ expect(result.encoding).to eq(Encoding.default_external)
end
- end
- it 'returns all contents if last_lines is not specified' do
- result = stream.raw
+ context 'limit max lines' do
+ before do
+ # specifying BUFFER_SIZE forces to seek backwards
+ allow(described_class).to receive(:BUFFER_SIZE)
+ .and_return(2)
+ end
- expect(result).to eq(lines.join)
- expect(result.encoding).to eq(Encoding.default_external)
- end
+ it 'returns last few lines' do
+ result = stream.raw(last_lines: 2)
- context 'limit max lines' do
- before do
- # specifying BUFFER_SIZE forces to seek backwards
- allow(described_class).to receive(:BUFFER_SIZE)
- .and_return(2)
+ expect(result).to eq(lines.last(2).join)
+ expect(result.encoding).to eq(Encoding.default_external)
+ end
+
+ it 'returns everything if trying to get too many lines' do
+ result = stream.raw(last_lines: lines.size * 2)
+
+ expect(result).to eq(lines.join)
+ expect(result.encoding).to eq(Encoding.default_external)
+ end
end
+ end
- it 'returns last few lines' do
- result = stream.raw(last_lines: 2)
+ let(:path) { __FILE__ }
+ let(:lines) { File.readlines(path) }
- expect(result).to eq(lines.last(2).join)
- expect(result.encoding).to eq(Encoding.default_external)
+ context 'when stream is File' do
+ let(:stream) do
+ described_class.new do
+ File.open(path)
+ end
end
- it 'returns everything if trying to get too many lines' do
- result = stream.raw(last_lines: lines.size * 2)
+ it_behaves_like 'sets'
+ end
- expect(result).to eq(lines.join)
- expect(result.encoding).to eq(Encoding.default_external)
+ context 'when stream is ChunkedIO' do
+ let(:stream) do
+ described_class.new do
+ Gitlab::Ci::Trace::ChunkedIO.new(build).tap do |chunked_io|
+ chunked_io.write(File.binread(path))
+ chunked_io.seek(0, IO::SEEK_SET)
+ end
+ end
end
+
+ it_behaves_like 'sets'
end
end
describe '#html_with_state' do
- let(:stream) do
- described_class.new do
- StringIO.new("1234")
+ shared_examples_for 'html_with_states' do
+ it 'returns html content with state' do
+ result = stream.html_with_state
+
+ expect(result.html).to eq("1234")
end
- end
- it 'returns html content with state' do
- result = stream.html_with_state
+ context 'follow-up state' do
+ let!(:last_result) { stream.html_with_state }
- expect(result.html).to eq("1234")
- end
+ before do
+ stream.append("5678", 4)
+ stream.seek(0)
+ end
- context 'follow-up state' do
- let!(:last_result) { stream.html_with_state }
+ it "returns appended trace" do
+ result = stream.html_with_state(last_result.state)
- before do
- stream.append("5678", 4)
- stream.seek(0)
+ expect(result.append).to be_truthy
+ expect(result.html).to eq("5678")
+ end
+ end
+ end
+
+ context 'when stream is StringIO' do
+ let(:stream) do
+ described_class.new do
+ StringIO.new("1234")
+ end
end
- it "returns appended trace" do
- result = stream.html_with_state(last_result.state)
+ it_behaves_like 'html_with_states'
+ end
- expect(result.append).to be_truthy
- expect(result.html).to eq("5678")
+ context 'when stream is ChunkedIO' do
+ let(:stream) do
+ described_class.new do
+ Gitlab::Ci::Trace::ChunkedIO.new(build).tap do |chunked_io|
+ chunked_io.write("1234")
+ chunked_io.seek(0, IO::SEEK_SET)
+ end
+ end
end
+
+ it_behaves_like 'html_with_states'
end
end
describe '#html' do
- let(:stream) do
- described_class.new do
- StringIO.new("12\n34\n56")
+ shared_examples_for 'htmls' do
+ it "returns html" do
+ expect(stream.html).to eq("12<br>34<br>56")
+ end
+
+ it "returns html for last line only" do
+ expect(stream.html(last_lines: 1)).to eq("56")
end
end
- it "returns html" do
- expect(stream.html).to eq("12<br>34<br>56")
+ context 'when stream is StringIO' do
+ let(:stream) do
+ described_class.new do
+ StringIO.new("12\n34\n56")
+ end
+ end
+
+ it_behaves_like 'htmls'
end
- it "returns html for last line only" do
- expect(stream.html(last_lines: 1)).to eq("56")
+ context 'when stream is ChunkedIO' do
+ let(:stream) do
+ described_class.new do
+ Gitlab::Ci::Trace::ChunkedIO.new(build).tap do |chunked_io|
+ chunked_io.write("12\n34\n56")
+ chunked_io.seek(0, IO::SEEK_SET)
+ end
+ end
+ end
+
+ it_behaves_like 'htmls'
end
end
describe '#extract_coverage' do
- let(:stream) do
- described_class.new do
- StringIO.new(data)
- end
- end
+ shared_examples_for 'extract_coverages' do
+ context 'valid content & regex' do
+ let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered' }
+ let(:regex) { '\(\d+.\d+\%\) covered' }
- subject { stream.extract_coverage(regex) }
+ it { is_expected.to eq("98.29") }
+ end
- context 'valid content & regex' do
- let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered' }
- let(:regex) { '\(\d+.\d+\%\) covered' }
+ context 'valid content & bad regex' do
+ let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' }
+ let(:regex) { 'very covered' }
- it { is_expected.to eq("98.29") }
- end
+ it { is_expected.to be_nil }
+ end
- context 'valid content & bad regex' do
- let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' }
- let(:regex) { 'very covered' }
+ context 'no coverage content & regex' do
+ let(:data) { 'No coverage for today :sad:' }
+ let(:regex) { '\(\d+.\d+\%\) covered' }
- it { is_expected.to be_nil }
- end
+ it { is_expected.to be_nil }
+ end
- context 'no coverage content & regex' do
- let(:data) { 'No coverage for today :sad:' }
- let(:regex) { '\(\d+.\d+\%\) covered' }
+ context 'multiple results in content & regex' do
+ let(:data) do
+ <<~HEREDOC
+ (98.39%) covered
+ (98.29%) covered
+ HEREDOC
+ end
- it { is_expected.to be_nil }
- end
+ let(:regex) { '\(\d+.\d+\%\) covered' }
- context 'multiple results in content & regex' do
- let(:data) do
- <<~HEREDOC
- (98.39%) covered
- (98.29%) covered
- HEREDOC
+ it 'returns the last matched coverage' do
+ is_expected.to eq("98.29")
+ end
end
- let(:regex) { '\(\d+.\d+\%\) covered' }
+ context 'when BUFFER_SIZE is smaller than stream.size' do
+ let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' }
+ let(:regex) { '\(\d+.\d+\%\) covered' }
- it 'returns the last matched coverage' do
- is_expected.to eq("98.29")
+ before do
+ stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', 5)
+ end
+
+ it { is_expected.to eq("98.29") }
end
- end
- context 'when BUFFER_SIZE is smaller than stream.size' do
- let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' }
- let(:regex) { '\(\d+.\d+\%\) covered' }
+ context 'when regex is multi-byte char' do
+ let(:data) { '95.0 ゴッドファット\n' }
+ let(:regex) { '\d+\.\d+ ゴッドファット' }
- before do
- stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', 5)
+ before do
+ stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', 5)
+ end
+
+ it { is_expected.to eq('95.0') }
end
- it { is_expected.to eq("98.29") }
- end
+ context 'when BUFFER_SIZE is equal to stream.size' do
+ let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' }
+ let(:regex) { '\(\d+.\d+\%\) covered' }
- context 'when regex is multi-byte char' do
- let(:data) { '95.0 ゴッドファット\n' }
- let(:regex) { '\d+\.\d+ ゴッドファット' }
+ before do
+ stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', data.length)
+ end
- before do
- stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', 5)
+ it { is_expected.to eq("98.29") }
end
- it { is_expected.to eq('95.0') }
- end
-
- context 'when BUFFER_SIZE is equal to stream.size' do
- let(:data) { 'Coverage 1033 / 1051 LOC (98.29%) covered\n' }
- let(:regex) { '\(\d+.\d+\%\) covered' }
+ context 'using a regex capture' do
+ let(:data) { 'TOTAL 9926 3489 65%' }
+ let(:regex) { 'TOTAL\s+\d+\s+\d+\s+(\d{1,3}\%)' }
- before do
- stub_const('Gitlab::Ci::Trace::Stream::BUFFER_SIZE', data.length)
+ it { is_expected.to eq("65") }
end
- it { is_expected.to eq("98.29") }
- end
+ context 'malicious regexp' do
+ let(:data) { malicious_text }
+ let(:regex) { malicious_regexp }
- context 'using a regex capture' do
- let(:data) { 'TOTAL 9926 3489 65%' }
- let(:regex) { 'TOTAL\s+\d+\s+\d+\s+(\d{1,3}\%)' }
+ include_examples 'malicious regexp'
+ end
- it { is_expected.to eq("65") }
- end
+ context 'multi-line data with rooted regexp' do
+ let(:data) { "\n65%\n" }
+ let(:regex) { '^(\d+)\%$' }
- context 'malicious regexp' do
- let(:data) { malicious_text }
- let(:regex) { malicious_regexp }
+ it { is_expected.to eq('65') }
+ end
- include_examples 'malicious regexp'
- end
+ context 'long line' do
+ let(:data) { 'a' * 80000 + '100%' + 'a' * 80000 }
+ let(:regex) { '\d+\%' }
- context 'multi-line data with rooted regexp' do
- let(:data) { "\n65%\n" }
- let(:regex) { '^(\d+)\%$' }
+ it { is_expected.to eq('100') }
+ end
- it { is_expected.to eq('65') }
- end
+ context 'many lines' do
+ let(:data) { "foo\n" * 80000 + "100%\n" + "foo\n" * 80000 }
+ let(:regex) { '\d+\%' }
- context 'long line' do
- let(:data) { 'a' * 80000 + '100%' + 'a' * 80000 }
- let(:regex) { '\d+\%' }
+ it { is_expected.to eq('100') }
+ end
- it { is_expected.to eq('100') }
- end
+ context 'empty regex' do
+ let(:data) { 'foo' }
+ let(:regex) { '' }
- context 'many lines' do
- let(:data) { "foo\n" * 80000 + "100%\n" + "foo\n" * 80000 }
- let(:regex) { '\d+\%' }
+ it 'skips processing' do
+ expect(stream).not_to receive(:read)
- it { is_expected.to eq('100') }
- end
+ is_expected.to be_nil
+ end
+ end
- context 'empty regex' do
- let(:data) { 'foo' }
- let(:regex) { '' }
+ context 'nil regex' do
+ let(:data) { 'foo' }
+ let(:regex) { nil }
- it 'skips processing' do
- expect(stream).not_to receive(:read)
+ it 'skips processing' do
+ expect(stream).not_to receive(:read)
- is_expected.to be_nil
+ is_expected.to be_nil
+ end
end
end
- context 'nil regex' do
- let(:data) { 'foo' }
- let(:regex) { nil }
+ subject { stream.extract_coverage(regex) }
- it 'skips processing' do
- expect(stream).not_to receive(:read)
+ context 'when stream is StringIO' do
+ let(:stream) do
+ described_class.new do
+ StringIO.new(data)
+ end
+ end
+
+ it_behaves_like 'extract_coverages'
+ end
- is_expected.to be_nil
+ context 'when stream is ChunkedIO' do
+ let(:stream) do
+ described_class.new do
+ Gitlab::Ci::Trace::ChunkedIO.new(build).tap do |chunked_io|
+ chunked_io.write(data)
+ chunked_io.seek(0, IO::SEEK_SET)
+ end
+ end
end
+
+ it_behaves_like 'extract_coverages'
end
end
end
diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb
index 6a9c6442282..e9d755c2021 100644
--- a/spec/lib/gitlab/ci/trace_spec.rb
+++ b/spec/lib/gitlab/ci/trace_spec.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-describe Gitlab::Ci::Trace do
+describe Gitlab::Ci::Trace, :clean_gitlab_redis_cache do
let(:build) { create(:ci_build) }
let(:trace) { described_class.new(build) }
@@ -9,552 +9,19 @@ describe Gitlab::Ci::Trace do
it { expect(trace).to delegate_method(:old_trace).to(:job) }
end
- describe '#html' do
+ context 'when live trace feature is disabled' do
before do
- trace.set("12\n34")
+ stub_feature_flags(ci_enable_live_trace: false)
end
- it "returns formatted html" do
- expect(trace.html).to eq("12<br>34")
- end
-
- it "returns last line of formatted html" do
- expect(trace.html(last_lines: 1)).to eq("34")
- end
- end
-
- describe '#raw' do
- before do
- trace.set("12\n34")
- end
-
- it "returns raw output" do
- expect(trace.raw).to eq("12\n34")
- end
-
- it "returns last line of raw output" do
- expect(trace.raw(last_lines: 1)).to eq("34")
- end
- end
-
- describe '#extract_coverage' do
- let(:regex) { '\(\d+.\d+\%\) covered' }
-
- context 'matching coverage' do
- before do
- trace.set('Coverage 1033 / 1051 LOC (98.29%) covered')
- end
-
- it "returns valid coverage" do
- expect(trace.extract_coverage(regex)).to eq("98.29")
- end
- end
-
- context 'no coverage' do
- before do
- trace.set('No coverage')
- end
-
- it 'returs nil' do
- expect(trace.extract_coverage(regex)).to be_nil
- end
- end
- end
-
- describe '#extract_sections' do
- let(:log) { 'No sections' }
- let(:sections) { trace.extract_sections }
-
- before do
- trace.set(log)
- end
-
- context 'no sections' do
- it 'returs []' do
- expect(trace.extract_sections).to eq([])
- end
- end
-
- context 'multiple sections available' do
- let(:log) { File.read(expand_fixture_path('trace/trace_with_sections')) }
- let(:sections_data) do
- [
- { name: 'prepare_script', lines: 2, duration: 3.seconds },
- { name: 'get_sources', lines: 4, duration: 1.second },
- { name: 'restore_cache', lines: 0, duration: 0.seconds },
- { name: 'download_artifacts', lines: 0, duration: 0.seconds },
- { name: 'build_script', lines: 2, duration: 1.second },
- { name: 'after_script', lines: 0, duration: 0.seconds },
- { name: 'archive_cache', lines: 0, duration: 0.seconds },
- { name: 'upload_artifacts', lines: 0, duration: 0.seconds }
- ]
- end
-
- it "returns valid sections" do
- expect(sections).not_to be_empty
- expect(sections.size).to eq(sections_data.size),
- "expected #{sections_data.size} sections, got #{sections.size}"
-
- buff = StringIO.new(log)
- sections.each_with_index do |s, i|
- expected = sections_data[i]
-
- expect(s[:name]).to eq(expected[:name])
- expect(s[:date_end] - s[:date_start]).to eq(expected[:duration])
-
- buff.seek(s[:byte_start], IO::SEEK_SET)
- length = s[:byte_end] - s[:byte_start]
- lines = buff.read(length).count("\n")
- expect(lines).to eq(expected[:lines])
- end
- end
- end
-
- context 'logs contains "section_start"' do
- let(:log) { "section_start:1506417476:a_section\r\033[0Klooks like a section_start:invalid\nsection_end:1506417477:a_section\r\033[0K"}
-
- it "returns only one section" do
- expect(sections).not_to be_empty
- expect(sections.size).to eq(1)
-
- section = sections[0]
- expect(section[:name]).to eq('a_section')
- expect(section[:byte_start]).not_to eq(section[:byte_end]), "got an empty section"
- end
- end
-
- context 'missing section_end' do
- let(:log) { "section_start:1506417476:a_section\r\033[0KSome logs\nNo section_end\n"}
-
- it "returns no sections" do
- expect(sections).to be_empty
- end
- end
-
- context 'missing section_start' do
- let(:log) { "Some logs\nNo section_start\nsection_end:1506417476:a_section\r\033[0K"}
-
- it "returns no sections" do
- expect(sections).to be_empty
- end
- end
-
- context 'inverted section_start section_end' do
- let(:log) { "section_end:1506417476:a_section\r\033[0Klooks like a section_start:invalid\nsection_start:1506417477:a_section\r\033[0K"}
-
- it "returns no sections" do
- expect(sections).to be_empty
- end
- end
- end
-
- describe '#set' do
- before do
- trace.set("12")
- end
-
- it "returns trace" do
- expect(trace.raw).to eq("12")
- end
-
- context 'overwrite trace' do
- before do
- trace.set("34")
- end
-
- it "returns new trace" do
- expect(trace.raw).to eq("34")
- end
- end
-
- context 'runners token' do
- let(:token) { 'my_secret_token' }
-
- before do
- build.project.update(runners_token: token)
- trace.set(token)
- end
-
- it "hides token" do
- expect(trace.raw).not_to include(token)
- end
- end
-
- context 'hides build token' do
- let(:token) { 'my_secret_token' }
-
- before do
- build.update(token: token)
- trace.set(token)
- end
-
- it "hides token" do
- expect(trace.raw).not_to include(token)
- end
- end
+ it_behaves_like 'trace with disabled live trace feature'
end
- describe '#append' do
+ context 'when live trace feature is enabled' do
before do
- trace.set("1234")
- end
-
- it "returns correct trace" do
- expect(trace.append("56", 4)).to eq(6)
- expect(trace.raw).to eq("123456")
- end
-
- context 'tries to append trace at different offset' do
- it "fails with append" do
- expect(trace.append("56", 2)).to eq(-4)
- expect(trace.raw).to eq("1234")
- end
- end
-
- context 'runners token' do
- let(:token) { 'my_secret_token' }
-
- before do
- build.project.update(runners_token: token)
- trace.append(token, 0)
- end
-
- it "hides token" do
- expect(trace.raw).not_to include(token)
- end
- end
-
- context 'build token' do
- let(:token) { 'my_secret_token' }
-
- before do
- build.update(token: token)
- trace.append(token, 0)
- end
-
- it "hides token" do
- expect(trace.raw).not_to include(token)
- end
- end
- end
-
- describe '#read' do
- shared_examples 'read successfully with IO' do
- it 'yields with source' do
- trace.read do |stream|
- expect(stream).to be_a(Gitlab::Ci::Trace::Stream)
- expect(stream.stream).to be_a(IO)
- end
- end
- end
-
- shared_examples 'read successfully with StringIO' do
- it 'yields with source' do
- trace.read do |stream|
- expect(stream).to be_a(Gitlab::Ci::Trace::Stream)
- expect(stream.stream).to be_a(StringIO)
- end
- end
- end
-
- shared_examples 'failed to read' do
- it 'yields without source' do
- trace.read do |stream|
- expect(stream).to be_a(Gitlab::Ci::Trace::Stream)
- expect(stream.stream).to be_nil
- end
- end
- end
-
- context 'when trace artifact exists' do
- before do
- create(:ci_job_artifact, :trace, job: build)
- end
-
- it_behaves_like 'read successfully with IO'
- end
-
- context 'when current_path (with project_id) exists' do
- before do
- expect(trace).to receive(:default_path) { expand_fixture_path('trace/sample_trace') }
- end
-
- it_behaves_like 'read successfully with IO'
- end
-
- context 'when current_path (with project_ci_id) exists' do
- before do
- expect(trace).to receive(:deprecated_path) { expand_fixture_path('trace/sample_trace') }
- end
-
- it_behaves_like 'read successfully with IO'
- end
-
- context 'when db trace exists' do
- before do
- build.send(:write_attribute, :trace, "data")
- end
-
- it_behaves_like 'read successfully with StringIO'
- end
-
- context 'when no sources exist' do
- it_behaves_like 'failed to read'
- end
- end
-
- describe 'trace handling' do
- subject { trace.exist? }
-
- context 'trace does not exist' do
- it { expect(trace.exist?).to be(false) }
- end
-
- context 'when trace artifact exists' do
- before do
- create(:ci_job_artifact, :trace, job: build)
- end
-
- it { is_expected.to be_truthy }
-
- context 'when the trace artifact has been erased' do
- before do
- trace.erase!
- end
-
- it { is_expected.to be_falsy }
-
- it 'removes associations' do
- expect(Ci::JobArtifact.exists?(job_id: build.id, file_type: :trace)).to be_falsy
- end
- end
- end
-
- context 'new trace path is used' do
- before do
- trace.send(:ensure_directory)
-
- File.open(trace.send(:default_path), "w") do |file|
- file.write("data")
- end
- end
-
- it "trace exist" do
- expect(trace.exist?).to be(true)
- end
-
- it "can be erased" do
- trace.erase!
- expect(trace.exist?).to be(false)
- end
- end
-
- context 'deprecated path' do
- let(:path) { trace.send(:deprecated_path) }
-
- context 'with valid ci_id' do
- before do
- build.project.update(ci_id: 1000)
-
- FileUtils.mkdir_p(File.dirname(path))
-
- File.open(path, "w") do |file|
- file.write("data")
- end
- end
-
- it "trace exist" do
- expect(trace.exist?).to be(true)
- end
-
- it "can be erased" do
- trace.erase!
- expect(trace.exist?).to be(false)
- end
- end
-
- context 'without valid ci_id' do
- it "does not return deprecated path" do
- expect(path).to be_nil
- end
- end
- end
-
- context 'stored in database' do
- before do
- build.send(:write_attribute, :trace, "data")
- end
-
- it "trace exist" do
- expect(trace.exist?).to be(true)
- end
-
- it "can be erased" do
- trace.erase!
- expect(trace.exist?).to be(false)
- end
-
- it "returns database data" do
- expect(trace.raw).to eq("data")
- end
- end
- end
-
- describe '#archive!' do
- subject { trace.archive! }
-
- shared_examples 'archive trace file' do
- it do
- expect { subject }.to change { Ci::JobArtifact.count }.by(1)
-
- build.reload
- expect(build.trace.exist?).to be_truthy
- expect(build.job_artifacts_trace.file.exists?).to be_truthy
- expect(build.job_artifacts_trace.file.filename).to eq('job.log')
- expect(File.exist?(src_path)).to be_falsy
- expect(src_checksum)
- .to eq(Digest::SHA256.file(build.job_artifacts_trace.file.path).hexdigest)
- expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum)
- end
- end
-
- shared_examples 'source trace file stays intact' do |error:|
- it do
- expect { subject }.to raise_error(error)
-
- build.reload
- expect(build.trace.exist?).to be_truthy
- expect(build.job_artifacts_trace).to be_nil
- expect(File.exist?(src_path)).to be_truthy
- end
- end
-
- shared_examples 'archive trace in database' do
- it do
- expect { subject }.to change { Ci::JobArtifact.count }.by(1)
-
- build.reload
- expect(build.trace.exist?).to be_truthy
- expect(build.job_artifacts_trace.file.exists?).to be_truthy
- expect(build.job_artifacts_trace.file.filename).to eq('job.log')
- expect(build.old_trace).to be_nil
- expect(src_checksum)
- .to eq(Digest::SHA256.file(build.job_artifacts_trace.file.path).hexdigest)
- expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum)
- end
- end
-
- shared_examples 'source trace in database stays intact' do |error:|
- it do
- expect { subject }.to raise_error(error)
-
- build.reload
- expect(build.trace.exist?).to be_truthy
- expect(build.job_artifacts_trace).to be_nil
- expect(build.old_trace).to eq(trace_content)
- end
- end
-
- context 'when job does not have trace artifact' do
- context 'when trace file stored in default path' do
- let!(:build) { create(:ci_build, :success, :trace_live) }
- let!(:src_path) { trace.read { |s| s.path } }
- let!(:src_checksum) { Digest::SHA256.file(src_path).hexdigest }
-
- it_behaves_like 'archive trace file'
-
- context 'when failed to create clone file' do
- before do
- allow(IO).to receive(:copy_stream).and_return(0)
- end
-
- it_behaves_like 'source trace file stays intact', error: Gitlab::Ci::Trace::ArchiveError
- end
-
- context 'when failed to create job artifact record' do
- before do
- allow_any_instance_of(Ci::JobArtifact).to receive(:save).and_return(false)
- allow_any_instance_of(Ci::JobArtifact).to receive_message_chain(:errors, :full_messages)
- .and_return(%w[Error Error])
- end
-
- it_behaves_like 'source trace file stays intact', error: ActiveRecord::RecordInvalid
- end
- end
-
- context 'when trace is stored in database' do
- let(:build) { create(:ci_build, :success) }
- let(:trace_content) { 'Sample trace' }
- let!(:src_checksum) { Digest::SHA256.hexdigest(trace_content) }
-
- before do
- build.update_column(:trace, trace_content)
- end
-
- it_behaves_like 'archive trace in database'
-
- context 'when failed to create clone file' do
- before do
- allow(IO).to receive(:copy_stream).and_return(0)
- end
-
- it_behaves_like 'source trace in database stays intact', error: Gitlab::Ci::Trace::ArchiveError
- end
-
- context 'when failed to create job artifact record' do
- before do
- allow_any_instance_of(Ci::JobArtifact).to receive(:save).and_return(false)
- allow_any_instance_of(Ci::JobArtifact).to receive_message_chain(:errors, :full_messages)
- .and_return(%w[Error Error])
- end
-
- it_behaves_like 'source trace in database stays intact', error: ActiveRecord::RecordInvalid
- end
-
- context 'when there is a validation error on Ci::Build' do
- before do
- allow_any_instance_of(Ci::Build).to receive(:save).and_return(false)
- allow_any_instance_of(Ci::Build).to receive_message_chain(:errors, :full_messages)
- .and_return(%w[Error Error])
- end
-
- context "when erase old trace with 'save'" do
- before do
- build.send(:write_attribute, :trace, nil)
- build.save
- end
-
- it 'old trace is not deleted' do
- build.reload
- expect(build.trace.raw).to eq(trace_content)
- end
- end
-
- it_behaves_like 'archive trace in database'
- end
- end
+ stub_feature_flags(ci_enable_live_trace: true)
end
- context 'when job has trace artifact' do
- before do
- create(:ci_job_artifact, :trace, job: build)
- end
-
- it 'does not archive' do
- expect_any_instance_of(described_class).not_to receive(:archive_stream!)
- expect { subject }.to raise_error('Already archived')
- expect(build.job_artifacts_trace.file.exists?).to be_truthy
- end
- end
-
- context 'when job is not finished yet' do
- let!(:build) { create(:ci_build, :running, :trace_live) }
-
- it 'does not archive' do
- expect_any_instance_of(described_class).not_to receive(:archive_stream!)
- expect { subject }.to raise_error('Job is not finished yet')
- expect(build.trace.exist?).to be_truthy
- end
- end
+ it_behaves_like 'trace with enabled live trace feature'
end
end
diff --git a/spec/lib/gitlab/data_builder/wiki_page_spec.rb b/spec/lib/gitlab/data_builder/wiki_page_spec.rb
index a776d888c47..9c8bdf4b032 100644
--- a/spec/lib/gitlab/data_builder/wiki_page_spec.rb
+++ b/spec/lib/gitlab/data_builder/wiki_page_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe Gitlab::DataBuilder::WikiPage do
- let(:project) { create(:project, :repository) }
+ set(:project) { create(:project, :repository, :wiki_repo) }
let(:wiki_page) { create(:wiki_page, wiki: project.wiki) }
let(:user) { create(:user) }
diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb
index 9924641f829..9f091975959 100644
--- a/spec/lib/gitlab/git/repository_spec.rb
+++ b/spec/lib/gitlab/git/repository_spec.rb
@@ -1068,41 +1068,51 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
describe '#raw_changes_between' do
- let(:old_rev) { }
- let(:new_rev) { }
- let(:changes) { repository.raw_changes_between(old_rev, new_rev) }
+ shared_examples 'raw changes' do
+ let(:old_rev) { }
+ let(:new_rev) { }
+ let(:changes) { repository.raw_changes_between(old_rev, new_rev) }
- context 'initial commit' do
- let(:old_rev) { Gitlab::Git::BLANK_SHA }
- let(:new_rev) { '1a0b36b3cdad1d2ee32457c102a8c0b7056fa863' }
+ context 'initial commit' do
+ let(:old_rev) { Gitlab::Git::BLANK_SHA }
+ let(:new_rev) { '1a0b36b3cdad1d2ee32457c102a8c0b7056fa863' }
- it 'returns the changes' do
- expect(changes).to be_present
- expect(changes.size).to eq(3)
+ it 'returns the changes' do
+ expect(changes).to be_present
+ expect(changes.size).to eq(3)
+ end
end
- end
- context 'with an invalid rev' do
- let(:old_rev) { 'foo' }
- let(:new_rev) { 'bar' }
+ context 'with an invalid rev' do
+ let(:old_rev) { 'foo' }
+ let(:new_rev) { 'bar' }
- it 'returns an error' do
- expect { changes }.to raise_error(Gitlab::Git::Repository::GitError)
+ it 'returns an error' do
+ expect { changes }.to raise_error(Gitlab::Git::Repository::GitError)
+ end
end
- end
- context 'with valid revs' do
- let(:old_rev) { 'fa1b1e6c004a68b7d8763b86455da9e6b23e36d6' }
- let(:new_rev) { '4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6' }
+ context 'with valid revs' do
+ let(:old_rev) { 'fa1b1e6c004a68b7d8763b86455da9e6b23e36d6' }
+ let(:new_rev) { '4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6' }
- it 'returns the changes' do
- expect(changes.size).to eq(9)
- expect(changes.first.operation).to eq(:modified)
- expect(changes.first.new_path).to eq('.gitmodules')
- expect(changes.last.operation).to eq(:added)
- expect(changes.last.new_path).to eq('files/lfs/picture-invalid.png')
+ it 'returns the changes' do
+ expect(changes.size).to eq(9)
+ expect(changes.first.operation).to eq(:modified)
+ expect(changes.first.new_path).to eq('.gitmodules')
+ expect(changes.last.operation).to eq(:added)
+ expect(changes.last.new_path).to eq('files/lfs/picture-invalid.png')
+ end
end
end
+
+ context 'when gitaly is enabled' do
+ it_behaves_like 'raw changes'
+ end
+
+ context 'when gitaly is disabled', :disable_gitaly do
+ it_behaves_like 'raw changes'
+ end
end
describe '#merge_base' do
diff --git a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
index ecd8657c406..1547d447197 100644
--- a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
@@ -167,4 +167,15 @@ describe Gitlab::GitalyClient::RepositoryService do
client.create_from_snapshot('http://example.com?wiki=1', 'Custom xyz')
end
end
+
+ describe '#raw_changes_between' do
+ it 'sends a create_repository_from_snapshot message' do
+ expect_any_instance_of(Gitaly::RepositoryService::Stub)
+ .to receive(:get_raw_changes)
+ .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
+ .and_return(double)
+
+ client.raw_changes_between('deadbeef', 'deadpork')
+ end
+ end
end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 830d91de983..ad76adcc2e5 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -268,6 +268,7 @@ project:
- pages_domains
- authorized_users
- project_authorizations
+- remote_mirrors
- route
- redirect_routes
- statistics
@@ -276,6 +277,7 @@ project:
- import_state
- members_and_requesters
- build_trace_section_names
+- build_trace_chunks
- root_of_fork_network
- fork_network_member
- fork_network
diff --git a/spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb b/spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb
index d2bd8ccdf3f..24bc231d5a0 100644
--- a/spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/wiki_repo_saver_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe Gitlab::ImportExport::WikiRepoSaver do
describe 'bundle a wiki Git repo' do
let(:user) { create(:user) }
- let!(:project) { create(:project, :public, name: 'searchable_project') }
+ let!(:project) { create(:project, :public, :wiki_repo, name: 'searchable_project') }
let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" }
let(:shared) { project.import_export_shared }
let(:wiki_bundler) { described_class.new(project: project, shared: shared) }
diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb
index 8351b967133..a34b7d9905a 100644
--- a/spec/lib/gitlab/project_search_results_spec.rb
+++ b/spec/lib/gitlab/project_search_results_spec.rb
@@ -175,14 +175,14 @@ describe Gitlab::ProjectSearchResults do
end
describe 'wiki search' do
- let(:project) { create(:project, :public) }
+ let(:project) { create(:project, :public, :wiki_repo) }
let(:wiki) { build(:project_wiki, project: project) }
let!(:wiki_page) { wiki.create_page('Title', 'Content') }
subject(:results) { described_class.new(user, project, 'Content').objects('wiki_blobs') }
context 'when wiki is disabled' do
- let(:project) { create(:project, :public, :wiki_disabled) }
+ let(:project) { create(:project, :public, :wiki_repo, :wiki_disabled) }
it 'hides wiki blobs from members' do
project.add_reporter(user)
@@ -196,7 +196,7 @@ describe Gitlab::ProjectSearchResults do
end
context 'when wiki is internal' do
- let(:project) { create(:project, :public, :wiki_private) }
+ let(:project) { create(:project, :public, :wiki_repo, :wiki_private) }
it 'finds wiki blobs for guest' do
project.add_guest(user)
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index 9e6aa109a4b..a716e6f5434 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -96,6 +96,7 @@ describe Gitlab::UsageData do
pages_domains
protected_branches
releases
+ remote_mirrors
snippets
todos
uploads
diff --git a/spec/migrations/cleanup_build_stage_migration_spec.rb b/spec/migrations/cleanup_build_stage_migration_spec.rb
new file mode 100644
index 00000000000..4d4d02aaa94
--- /dev/null
+++ b/spec/migrations/cleanup_build_stage_migration_spec.rb
@@ -0,0 +1,53 @@
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20180420010616_cleanup_build_stage_migration.rb')
+
+describe CleanupBuildStageMigration, :migration, :sidekiq, :redis do
+ let(:migration) { spy('migration') }
+
+ before do
+ allow(Gitlab::BackgroundMigration::MigrateBuildStage)
+ .to receive(:new).and_return(migration)
+ end
+
+ context 'when there are pending background migrations' do
+ it 'processes pending jobs synchronously' do
+ Sidekiq::Testing.disable! do
+ BackgroundMigrationWorker
+ .perform_in(2.minutes, 'MigrateBuildStage', [1, 1])
+ BackgroundMigrationWorker
+ .perform_async('MigrateBuildStage', [1, 1])
+
+ migrate!
+
+ expect(migration).to have_received(:perform).with(1, 1).twice
+ end
+ end
+ end
+
+ context 'when there are no background migrations pending' do
+ it 'does nothing' do
+ Sidekiq::Testing.disable! do
+ migrate!
+
+ expect(migration).not_to have_received(:perform)
+ end
+ end
+ end
+
+ context 'when there are still unmigrated builds present' do
+ let(:builds) { table('ci_builds') }
+
+ before do
+ builds.create!(name: 'test:1', ref: 'master')
+ builds.create!(name: 'test:2', ref: 'master')
+ end
+
+ it 'migrates stages sequentially in batches' do
+ expect(builds.all).to all(have_attributes(stage_id: nil))
+
+ migrate!
+
+ expect(migration).to have_received(:perform).once
+ end
+ end
+end
diff --git a/spec/models/blob_viewer/readme_spec.rb b/spec/models/blob_viewer/readme_spec.rb
index b9946c0315a..8d11d58cfca 100644
--- a/spec/models/blob_viewer/readme_spec.rb
+++ b/spec/models/blob_viewer/readme_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe BlobViewer::Readme do
include FakeBlobHelpers
- let(:project) { create(:project, :repository) }
+ let(:project) { create(:project, :repository, :wiki_repo) }
let(:blob) { fake_blob(path: 'README.md') }
subject { described_class.new(blob) }
diff --git a/spec/models/ci/build_trace_chunk_spec.rb b/spec/models/ci/build_trace_chunk_spec.rb
new file mode 100644
index 00000000000..cbcf1e55979
--- /dev/null
+++ b/spec/models/ci/build_trace_chunk_spec.rb
@@ -0,0 +1,396 @@
+require 'spec_helper'
+
+describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
+ set(:build) { create(:ci_build, :running) }
+ let(:chunk_index) { 0 }
+ let(:data_store) { :redis }
+ let(:raw_data) { nil }
+
+ let(:build_trace_chunk) do
+ described_class.new(build: build, chunk_index: chunk_index, data_store: data_store, raw_data: raw_data)
+ end
+
+ before do
+ stub_feature_flags(ci_enable_live_trace: true)
+ end
+
+ context 'FastDestroyAll' do
+ let(:parent) { create(:project) }
+ let(:pipeline) { create(:ci_pipeline, project: parent) }
+ let(:build) { create(:ci_build, :running, :trace_live, pipeline: pipeline, project: parent) }
+ let(:subjects) { build.trace_chunks }
+
+ it_behaves_like 'fast destroyable'
+
+ def external_data_counter
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.scan_each(match: "gitlab:ci:trace:*:chunks:*").to_a.size
+ end
+ end
+ end
+
+ describe 'CHUNK_SIZE' do
+ it 'Chunk size can not be changed without special care' do
+ expect(described_class::CHUNK_SIZE).to eq(128.kilobytes)
+ end
+ end
+
+ describe '#data' do
+ subject { build_trace_chunk.data }
+
+ context 'when data_store is redis' do
+ let(:data_store) { :redis }
+
+ before do
+ build_trace_chunk.send(:redis_set_data, 'Sample data in redis')
+ end
+
+ it { is_expected.to eq('Sample data in redis') }
+ end
+
+ context 'when data_store is database' do
+ let(:data_store) { :db }
+ let(:raw_data) { 'Sample data in db' }
+
+ it { is_expected.to eq('Sample data in db') }
+ end
+
+ context 'when data_store is others' do
+ before do
+ build_trace_chunk.send(:write_attribute, :data_store, -1)
+ end
+
+ it { expect { subject }.to raise_error('Unsupported data store') }
+ end
+ end
+
+ describe '#set_data' do
+ subject { build_trace_chunk.send(:set_data, value) }
+
+ let(:value) { 'Sample data' }
+
+ context 'when value bytesize is bigger than CHUNK_SIZE' do
+ let(:value) { 'a' * (described_class::CHUNK_SIZE + 1) }
+
+ it { expect { subject }.to raise_error('too much data') }
+ end
+
+ context 'when data_store is redis' do
+ let(:data_store) { :redis }
+
+ it do
+ expect(build_trace_chunk.send(:redis_data)).to be_nil
+
+ subject
+
+ expect(build_trace_chunk.send(:redis_data)).to eq(value)
+ end
+
+ context 'when fullfilled chunk size' do
+ let(:value) { 'a' * described_class::CHUNK_SIZE }
+
+ it 'schedules stashing data' do
+ expect(Ci::BuildTraceChunkFlushWorker).to receive(:perform_async).once
+
+ subject
+ end
+ end
+ end
+
+ context 'when data_store is database' do
+ let(:data_store) { :db }
+
+ it 'sets data' do
+ expect(build_trace_chunk.raw_data).to be_nil
+
+ subject
+
+ expect(build_trace_chunk.raw_data).to eq(value)
+ expect(build_trace_chunk.persisted?).to be_truthy
+ end
+
+ context 'when raw_data is not changed' do
+ it 'does not execute UPDATE' do
+ expect(build_trace_chunk.raw_data).to be_nil
+ build_trace_chunk.save!
+
+ # First set
+ expect(ActiveRecord::QueryRecorder.new { subject }.count).to be > 0
+ expect(build_trace_chunk.raw_data).to eq(value)
+ expect(build_trace_chunk.persisted?).to be_truthy
+
+ # Second set
+ build_trace_chunk.reload
+ expect(ActiveRecord::QueryRecorder.new { subject }.count).to be(0)
+ end
+ end
+
+ context 'when fullfilled chunk size' do
+ it 'does not schedule stashing data' do
+ expect(Ci::BuildTraceChunkFlushWorker).not_to receive(:perform_async)
+
+ subject
+ end
+ end
+ end
+
+ context 'when data_store is others' do
+ before do
+ build_trace_chunk.send(:write_attribute, :data_store, -1)
+ end
+
+ it { expect { subject }.to raise_error('Unsupported data store') }
+ end
+ end
+
+ describe '#truncate' do
+ subject { build_trace_chunk.truncate(offset) }
+
+ shared_examples_for 'truncates' do
+ context 'when offset is negative' do
+ let(:offset) { -1 }
+
+ it { expect { subject }.to raise_error('Offset is out of range') }
+ end
+
+ context 'when offset is bigger than data size' do
+ let(:offset) { data.bytesize + 1 }
+
+ it { expect { subject }.to raise_error('Offset is out of range') }
+ end
+
+ context 'when offset is 10' do
+ let(:offset) { 10 }
+
+ it 'truncates' do
+ subject
+
+ expect(build_trace_chunk.data).to eq(data.byteslice(0, offset))
+ end
+ end
+ end
+
+ context 'when data_store is redis' do
+ let(:data_store) { :redis }
+ let(:data) { 'Sample data in redis' }
+
+ before do
+ build_trace_chunk.send(:redis_set_data, data)
+ end
+
+ it_behaves_like 'truncates'
+ end
+
+ context 'when data_store is database' do
+ let(:data_store) { :db }
+ let(:raw_data) { 'Sample data in db' }
+ let(:data) { raw_data }
+
+ it_behaves_like 'truncates'
+ end
+ end
+
+ describe '#append' do
+ subject { build_trace_chunk.append(new_data, offset) }
+
+ let(:new_data) { 'Sample new data' }
+ let(:offset) { 0 }
+ let(:total_data) { data + new_data }
+
+ shared_examples_for 'appends' do
+ context 'when offset is negative' do
+ let(:offset) { -1 }
+
+ it { expect { subject }.to raise_error('Offset is out of range') }
+ end
+
+ context 'when offset is bigger than data size' do
+ let(:offset) { data.bytesize + 1 }
+
+ it { expect { subject }.to raise_error('Offset is out of range') }
+ end
+
+ context 'when offset is bigger than data size' do
+ let(:new_data) { 'a' * (described_class::CHUNK_SIZE + 1) }
+
+ it { expect { subject }.to raise_error('Chunk size overflow') }
+ end
+
+ context 'when offset is EOF' do
+ let(:offset) { data.bytesize }
+
+ it 'appends' do
+ subject
+
+ expect(build_trace_chunk.data).to eq(total_data)
+ end
+ end
+
+ context 'when offset is 10' do
+ let(:offset) { 10 }
+
+ it 'appends' do
+ subject
+
+ expect(build_trace_chunk.data).to eq(data.byteslice(0, offset) + new_data)
+ end
+ end
+ end
+
+ context 'when data_store is redis' do
+ let(:data_store) { :redis }
+ let(:data) { 'Sample data in redis' }
+
+ before do
+ build_trace_chunk.send(:redis_set_data, data)
+ end
+
+ it_behaves_like 'appends'
+ end
+
+ context 'when data_store is database' do
+ let(:data_store) { :db }
+ let(:raw_data) { 'Sample data in db' }
+ let(:data) { raw_data }
+
+ it_behaves_like 'appends'
+ end
+ end
+
+ describe '#size' do
+ subject { build_trace_chunk.size }
+
+ context 'when data_store is redis' do
+ let(:data_store) { :redis }
+
+ context 'when data exists' do
+ let(:data) { 'Sample data in redis' }
+
+ before do
+ build_trace_chunk.send(:redis_set_data, data)
+ end
+
+ it { is_expected.to eq(data.bytesize) }
+ end
+
+ context 'when data exists' do
+ it { is_expected.to eq(0) }
+ end
+ end
+
+ context 'when data_store is database' do
+ let(:data_store) { :db }
+
+ context 'when data exists' do
+ let(:raw_data) { 'Sample data in db' }
+ let(:data) { raw_data }
+
+ it { is_expected.to eq(data.bytesize) }
+ end
+
+ context 'when data does not exist' do
+ it { is_expected.to eq(0) }
+ end
+ end
+ end
+
+ describe '#use_database!' do
+ subject { build_trace_chunk.use_database! }
+
+ context 'when data_store is redis' do
+ let(:data_store) { :redis }
+
+ context 'when data exists' do
+ let(:data) { 'Sample data in redis' }
+
+ before do
+ build_trace_chunk.send(:redis_set_data, data)
+ end
+
+ it 'stashes the data' do
+ expect(build_trace_chunk.data_store).to eq('redis')
+ expect(build_trace_chunk.send(:redis_data)).to eq(data)
+ expect(build_trace_chunk.raw_data).to be_nil
+
+ subject
+
+ expect(build_trace_chunk.data_store).to eq('db')
+ expect(build_trace_chunk.send(:redis_data)).to be_nil
+ expect(build_trace_chunk.raw_data).to eq(data)
+ end
+ end
+
+ context 'when data does not exist' do
+ it 'does not call UPDATE' do
+ expect(ActiveRecord::QueryRecorder.new { subject }.count).to eq(0)
+ end
+ end
+ end
+
+ context 'when data_store is database' do
+ let(:data_store) { :db }
+
+ it 'does not call UPDATE' do
+ expect(ActiveRecord::QueryRecorder.new { subject }.count).to eq(0)
+ end
+ end
+ end
+
+ describe 'ExclusiveLock' do
+ before do
+ allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain) { nil }
+ stub_const('Ci::BuildTraceChunk::WRITE_LOCK_RETRY', 1)
+ end
+
+ it 'raise an error' do
+ expect { build_trace_chunk.append('ABC', 0) }.to raise_error('Failed to obtain write lock')
+ end
+ end
+
+ describe 'deletes data in redis after a parent record destroyed' do
+ let(:project) { create(:project) }
+
+ before do
+ pipeline = create(:ci_pipeline, project: project)
+ create(:ci_build, :running, :trace_live, pipeline: pipeline, project: project)
+ create(:ci_build, :running, :trace_live, pipeline: pipeline, project: project)
+ create(:ci_build, :running, :trace_live, pipeline: pipeline, project: project)
+ end
+
+ shared_examples_for 'deletes all build_trace_chunk and data in redis' do
+ it do
+ Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.scan_each(match: "gitlab:ci:trace:*:chunks:*").to_a.size).to eq(3)
+ end
+
+ expect(described_class.count).to eq(3)
+
+ subject
+
+ expect(described_class.count).to eq(0)
+
+ Gitlab::Redis::SharedState.with do |redis|
+ expect(redis.scan_each(match: "gitlab:ci:trace:*:chunks:*").to_a.size).to eq(0)
+ end
+ end
+ end
+
+ context 'when traces are archived' do
+ let(:subject) do
+ project.builds.each do |build|
+ build.success!
+ end
+ end
+
+ it_behaves_like 'deletes all build_trace_chunk and data in redis'
+ end
+
+ context 'when project is destroyed' do
+ let(:subject) do
+ project.destroy!
+ end
+
+ it_behaves_like 'deletes all build_trace_chunk and data in redis'
+ end
+ end
+end
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index d620943693c..0907d28d33b 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -424,6 +424,95 @@ describe Group do
end
end
+ describe '#direct_and_indirect_members', :nested_groups do
+ let!(:group) { create(:group, :nested) }
+ let!(:sub_group) { create(:group, parent: group) }
+ let!(:master) { group.parent.add_user(create(:user), GroupMember::MASTER) }
+ let!(:developer) { group.add_user(create(:user), GroupMember::DEVELOPER) }
+ let!(:other_developer) { group.add_user(create(:user), GroupMember::DEVELOPER) }
+
+ it 'returns parents members' do
+ expect(group.direct_and_indirect_members).to include(developer)
+ expect(group.direct_and_indirect_members).to include(master)
+ end
+
+ it 'returns descendant members' do
+ expect(group.direct_and_indirect_members).to include(other_developer)
+ end
+ end
+
+ describe '#users_with_descendants', :nested_groups do
+ let(:user_a) { create(:user) }
+ let(:user_b) { create(:user) }
+
+ let(:group) { create(:group) }
+ let(:nested_group) { create(:group, parent: group) }
+ let(:deep_nested_group) { create(:group, parent: nested_group) }
+
+ it 'returns member users on every nest level without duplication' do
+ group.add_developer(user_a)
+ nested_group.add_developer(user_b)
+ deep_nested_group.add_developer(user_a)
+
+ expect(group.users_with_descendants).to contain_exactly(user_a, user_b)
+ expect(nested_group.users_with_descendants).to contain_exactly(user_a, user_b)
+ expect(deep_nested_group.users_with_descendants).to contain_exactly(user_a)
+ end
+ end
+
+ describe '#direct_and_indirect_users', :nested_groups do
+ let(:user_a) { create(:user) }
+ let(:user_b) { create(:user) }
+ let(:user_c) { create(:user) }
+ let(:user_d) { create(:user) }
+
+ let(:group) { create(:group) }
+ let(:nested_group) { create(:group, parent: group) }
+ let(:deep_nested_group) { create(:group, parent: nested_group) }
+ let(:project) { create(:project, namespace: group) }
+
+ before do
+ group.add_developer(user_a)
+ group.add_developer(user_c)
+ nested_group.add_developer(user_b)
+ deep_nested_group.add_developer(user_a)
+ project.add_developer(user_d)
+ end
+
+ it 'returns member users on every nest level without duplication' do
+ expect(group.direct_and_indirect_users).to contain_exactly(user_a, user_b, user_c, user_d)
+ expect(nested_group.direct_and_indirect_users).to contain_exactly(user_a, user_b, user_c)
+ expect(deep_nested_group.direct_and_indirect_users).to contain_exactly(user_a, user_b, user_c)
+ end
+
+ it 'does not return members of projects belonging to ancestor groups' do
+ expect(nested_group.direct_and_indirect_users).not_to include(user_d)
+ end
+ end
+
+ describe '#project_users_with_descendants', :nested_groups do
+ let(:user_a) { create(:user) }
+ let(:user_b) { create(:user) }
+ let(:user_c) { create(:user) }
+
+ let(:group) { create(:group) }
+ let(:nested_group) { create(:group, parent: group) }
+ let(:deep_nested_group) { create(:group, parent: nested_group) }
+ let(:project_a) { create(:project, namespace: group) }
+ let(:project_b) { create(:project, namespace: nested_group) }
+ let(:project_c) { create(:project, namespace: deep_nested_group) }
+
+ it 'returns members of all projects in group and subgroups' do
+ project_a.add_developer(user_a)
+ project_b.add_developer(user_b)
+ project_c.add_developer(user_c)
+
+ expect(group.project_users_with_descendants).to contain_exactly(user_a, user_b, user_c)
+ expect(nested_group.project_users_with_descendants).to contain_exactly(user_b, user_c)
+ expect(deep_nested_group.project_users_with_descendants).to contain_exactly(user_c)
+ end
+ end
+
describe '#user_ids_for_project_authorizations' do
it 'returns the user IDs for which to refresh authorizations' do
master = create(:user)
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index 506057dce87..6f702d8d95e 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -399,6 +399,21 @@ describe Namespace do
end
end
+ describe '#self_and_hierarchy', :nested_groups do
+ let!(:group) { create(:group, path: 'git_lab') }
+ let!(:nested_group) { create(:group, parent: group) }
+ let!(:deep_nested_group) { create(:group, parent: nested_group) }
+ let!(:very_deep_nested_group) { create(:group, parent: deep_nested_group) }
+ let!(:another_group) { create(:group, path: 'gitllab') }
+ let!(:another_group_nested) { create(:group, path: 'foo', parent: another_group) }
+
+ it 'returns the correct tree' do
+ expect(group.self_and_hierarchy).to contain_exactly(group, nested_group, deep_nested_group, very_deep_nested_group)
+ expect(nested_group.self_and_hierarchy).to contain_exactly(group, nested_group, deep_nested_group, very_deep_nested_group)
+ expect(very_deep_nested_group.self_and_hierarchy).to contain_exactly(group, nested_group, deep_nested_group, very_deep_nested_group)
+ end
+ end
+
describe '#ancestors', :nested_groups do
let(:group) { create(:group) }
let(:nested_group) { create(:group, parent: group) }
diff --git a/spec/models/project_services/microsoft_teams_service_spec.rb b/spec/models/project_services/microsoft_teams_service_spec.rb
index 733086e258f..8d9ee96227f 100644
--- a/spec/models/project_services/microsoft_teams_service_spec.rb
+++ b/spec/models/project_services/microsoft_teams_service_spec.rb
@@ -30,7 +30,7 @@ describe MicrosoftTeamsService do
describe "#execute" do
let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
+ set(:project) { create(:project, :repository, :wiki_repo) }
before do
allow(chat_service).to receive_messages(
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index f3cf21cf279..41622fbbb6f 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -1852,6 +1852,85 @@ describe Project do
it { expect(project.gitea_import?).to be true }
end
+ describe '#has_remote_mirror?' do
+ let(:project) { create(:project, :remote_mirror, :import_started) }
+ subject { project.has_remote_mirror? }
+
+ before do
+ allow_any_instance_of(RemoteMirror).to receive(:refresh_remote)
+ end
+
+ it 'returns true when a remote mirror is enabled' do
+ is_expected.to be_truthy
+ end
+
+ it 'returns false when remote mirror is disabled' do
+ project.remote_mirrors.first.update_attributes(enabled: false)
+
+ is_expected.to be_falsy
+ end
+ end
+
+ describe '#update_remote_mirrors' do
+ let(:project) { create(:project, :remote_mirror, :import_started) }
+ delegate :update_remote_mirrors, to: :project
+
+ before do
+ allow_any_instance_of(RemoteMirror).to receive(:refresh_remote)
+ end
+
+ it 'syncs enabled remote mirror' do
+ expect_any_instance_of(RemoteMirror).to receive(:sync)
+
+ update_remote_mirrors
+ end
+
+ # TODO: study if remote_mirror_available_overridden is still a necessary attribute considering that
+ # it is no longer under any license
+ it 'does nothing when remote mirror is disabled globally and not overridden' do
+ stub_application_setting(mirror_available: false)
+ project.remote_mirror_available_overridden = false
+
+ expect_any_instance_of(RemoteMirror).not_to receive(:sync)
+
+ update_remote_mirrors
+ end
+
+ it 'does not sync disabled remote mirrors' do
+ project.remote_mirrors.first.update_attributes(enabled: false)
+
+ expect_any_instance_of(RemoteMirror).not_to receive(:sync)
+
+ update_remote_mirrors
+ end
+ end
+
+ describe '#remote_mirror_available?' do
+ let(:project) { create(:project) }
+
+ context 'when remote mirror global setting is enabled' do
+ it 'returns true' do
+ expect(project.remote_mirror_available?).to be(true)
+ end
+ end
+
+ context 'when remote mirror global setting is disabled' do
+ before do
+ stub_application_setting(mirror_available: false)
+ end
+
+ it 'returns true when overridden' do
+ project.remote_mirror_available_overridden = true
+
+ expect(project.remote_mirror_available?).to be(true)
+ end
+
+ it 'returns false when not overridden' do
+ expect(project.remote_mirror_available?).to be(false)
+ end
+ end
+ end
+
describe '#ancestors_upto', :nested_groups do
let(:parent) { create(:group) }
let(:child) { create(:group, parent: parent) }
diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb
index cbe7d111fcd..d6c4031329d 100644
--- a/spec/models/project_wiki_spec.rb
+++ b/spec/models/project_wiki_spec.rb
@@ -1,7 +1,7 @@
require "spec_helper"
describe ProjectWiki do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :wiki_repo) }
let(:repository) { project.repository }
let(:user) { project.owner }
let(:gitlab_shell) { Gitlab::Shell.new }
@@ -328,6 +328,8 @@ describe ProjectWiki do
end
describe '#create_repo!' do
+ let(:project) { create(:project) }
+
it 'creates a repository' do
expect(raw_repository.exists?).to eq(false)
expect(subject.repository).to receive(:after_create)
@@ -339,6 +341,8 @@ describe ProjectWiki do
end
describe '#ensure_repository' do
+ let(:project) { create(:project) }
+
it 'creates the repository if it not exist' do
expect(raw_repository.exists?).to eq(false)
diff --git a/spec/models/remote_mirror_spec.rb b/spec/models/remote_mirror_spec.rb
new file mode 100644
index 00000000000..a80800c6c92
--- /dev/null
+++ b/spec/models/remote_mirror_spec.rb
@@ -0,0 +1,267 @@
+require 'rails_helper'
+
+describe RemoteMirror do
+ describe 'URL validation' do
+ context 'with a valid URL' do
+ it 'should be valid' do
+ remote_mirror = build(:remote_mirror)
+ expect(remote_mirror).to be_valid
+ end
+ end
+
+ context 'with an invalid URL' do
+ it 'should not be valid' do
+ remote_mirror = build(:remote_mirror, url: 'ftp://invalid.invalid')
+ expect(remote_mirror).not_to be_valid
+ expect(remote_mirror.errors[:url].size).to eq(2)
+ end
+ end
+ end
+
+ describe 'encrypting credentials' do
+ context 'when setting URL for a first time' do
+ it 'stores the URL without credentials' do
+ mirror = create_mirror(url: 'http://foo:bar@test.com')
+
+ expect(mirror.read_attribute(:url)).to eq('http://test.com')
+ end
+
+ it 'stores the credentials on a separate field' do
+ mirror = create_mirror(url: 'http://foo:bar@test.com')
+
+ expect(mirror.credentials).to eq({ user: 'foo', password: 'bar' })
+ end
+
+ it 'handles credentials with large content' do
+ mirror = create_mirror(url: 'http://bxnhm8dote33ct932r3xavslj81wxmr7o8yux8do10oozckkif:9ne7fuvjn40qjt35dgt8v86q9m9g9essryxj76sumg2ccl2fg26c0krtz2gzfpyq4hf22h328uhq6npuiq6h53tpagtsj7vsrz75@test.com')
+
+ expect(mirror.credentials).to eq({
+ user: 'bxnhm8dote33ct932r3xavslj81wxmr7o8yux8do10oozckkif',
+ password: '9ne7fuvjn40qjt35dgt8v86q9m9g9essryxj76sumg2ccl2fg26c0krtz2gzfpyq4hf22h328uhq6npuiq6h53tpagtsj7vsrz75'
+ })
+ end
+ end
+
+ context 'when updating the URL' do
+ it 'allows a new URL without credentials' do
+ mirror = create_mirror(url: 'http://foo:bar@test.com')
+
+ mirror.update_attribute(:url, 'http://test.com')
+
+ expect(mirror.url).to eq('http://test.com')
+ expect(mirror.credentials).to eq({ user: nil, password: nil })
+ end
+
+ it 'allows a new URL with credentials' do
+ mirror = create_mirror(url: 'http://test.com')
+
+ mirror.update_attribute(:url, 'http://foo:bar@test.com')
+
+ expect(mirror.url).to eq('http://foo:bar@test.com')
+ expect(mirror.credentials).to eq({ user: 'foo', password: 'bar' })
+ end
+
+ it 'updates the remote config if credentials changed' do
+ mirror = create_mirror(url: 'http://foo:bar@test.com')
+ repo = mirror.project.repository
+
+ mirror.update_attribute(:url, 'http://foo:baz@test.com')
+
+ config = repo.raw_repository.rugged.config
+ expect(config["remote.#{mirror.remote_name}.url"]).to eq('http://foo:baz@test.com')
+ end
+
+ it 'removes previous remote' do
+ mirror = create_mirror(url: 'http://foo:bar@test.com')
+
+ expect(RepositoryRemoveRemoteWorker).to receive(:perform_async).with(mirror.project.id, mirror.remote_name).and_call_original
+
+ mirror.update_attributes(url: 'http://test.com')
+ end
+ end
+ end
+
+ describe '#remote_name' do
+ context 'when remote name is persisted in the database' do
+ it 'returns remote name with random value' do
+ allow(SecureRandom).to receive(:hex).and_return('secret')
+
+ remote_mirror = create(:remote_mirror)
+
+ expect(remote_mirror.remote_name).to eq("remote_mirror_secret")
+ end
+ end
+
+ context 'when remote name is not persisted in the database' do
+ it 'returns remote name with remote mirror id' do
+ remote_mirror = create(:remote_mirror)
+ remote_mirror.remote_name = nil
+
+ expect(remote_mirror.remote_name).to eq("remote_mirror_#{remote_mirror.id}")
+ end
+ end
+
+ context 'when remote is not persisted in the database' do
+ it 'returns nil' do
+ remote_mirror = build(:remote_mirror, remote_name: nil)
+
+ expect(remote_mirror.remote_name).to be_nil
+ end
+ end
+ end
+
+ describe '#safe_url' do
+ context 'when URL contains credentials' do
+ it 'masks the credentials' do
+ mirror = create_mirror(url: 'http://foo:bar@test.com')
+
+ expect(mirror.safe_url).to eq('http://*****:*****@test.com')
+ end
+ end
+
+ context 'when URL does not contain credentials' do
+ it 'shows the full URL' do
+ mirror = create_mirror(url: 'http://test.com')
+
+ expect(mirror.safe_url).to eq('http://test.com')
+ end
+ end
+ end
+
+ context 'when remote mirror gets destroyed' do
+ it 'removes remote' do
+ mirror = create_mirror(url: 'http://foo:bar@test.com')
+
+ expect(RepositoryRemoveRemoteWorker).to receive(:perform_async).with(mirror.project.id, mirror.remote_name).and_call_original
+
+ mirror.destroy!
+ end
+ end
+
+ context 'stuck mirrors' do
+ it 'includes mirrors stuck in started with no last_update_at set' do
+ mirror = create_mirror(url: 'http://cantbeblank',
+ update_status: 'started',
+ last_update_at: nil,
+ updated_at: 25.hours.ago)
+
+ expect(described_class.stuck.last).to eq(mirror)
+ end
+ end
+
+ context '#sync' do
+ let(:remote_mirror) { create(:project, :repository, :remote_mirror).remote_mirrors.first }
+
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ context 'with remote mirroring disabled' do
+ it 'returns nil' do
+ remote_mirror.update_attributes(enabled: false)
+
+ expect(remote_mirror.sync).to be_nil
+ end
+ end
+
+ context 'with remote mirroring enabled' do
+ context 'with only protected branches enabled' do
+ context 'when it did not update in the last minute' do
+ it 'schedules a RepositoryUpdateRemoteMirrorWorker to run now' do
+ expect(RepositoryUpdateRemoteMirrorWorker).to receive(:perform_async).with(remote_mirror.id, Time.now)
+
+ remote_mirror.sync
+ end
+ end
+
+ context 'when it did update in the last minute' do
+ it 'schedules a RepositoryUpdateRemoteMirrorWorker to run in the next minute' do
+ remote_mirror.last_update_started_at = Time.now - 30.seconds
+
+ expect(RepositoryUpdateRemoteMirrorWorker).to receive(:perform_in).with(RemoteMirror::PROTECTED_BACKOFF_DELAY, remote_mirror.id, Time.now)
+
+ remote_mirror.sync
+ end
+ end
+ end
+
+ context 'with only protected branches disabled' do
+ before do
+ remote_mirror.only_protected_branches = false
+ end
+
+ context 'when it did not update in the last 5 minutes' do
+ it 'schedules a RepositoryUpdateRemoteMirrorWorker to run now' do
+ expect(RepositoryUpdateRemoteMirrorWorker).to receive(:perform_async).with(remote_mirror.id, Time.now)
+
+ remote_mirror.sync
+ end
+ end
+
+ context 'when it did update within the last 5 minutes' do
+ it 'schedules a RepositoryUpdateRemoteMirrorWorker to run in the next 5 minutes' do
+ remote_mirror.last_update_started_at = Time.now - 30.seconds
+
+ expect(RepositoryUpdateRemoteMirrorWorker).to receive(:perform_in).with(RemoteMirror::UNPROTECTED_BACKOFF_DELAY, remote_mirror.id, Time.now)
+
+ remote_mirror.sync
+ end
+ end
+ end
+ end
+ end
+
+ context '#updated_since?' do
+ let(:remote_mirror) { create(:project, :repository, :remote_mirror).remote_mirrors.first }
+ let(:timestamp) { Time.now - 5.minutes }
+
+ around do |example|
+ Timecop.freeze { example.run }
+ end
+
+ before do
+ remote_mirror.update_attributes(last_update_started_at: Time.now)
+ end
+
+ context 'when remote mirror does not have status failed' do
+ it 'returns true when last update started after the timestamp' do
+ expect(remote_mirror.updated_since?(timestamp)).to be true
+ end
+
+ it 'returns false when last update started before the timestamp' do
+ expect(remote_mirror.updated_since?(Time.now + 5.minutes)).to be false
+ end
+ end
+
+ context 'when remote mirror has status failed' do
+ it 'returns false when last update started after the timestamp' do
+ remote_mirror.update_attributes(update_status: 'failed')
+
+ expect(remote_mirror.updated_since?(timestamp)).to be false
+ end
+ end
+ end
+
+ context 'no project' do
+ it 'includes mirror with a project in pending_delete' do
+ mirror = create_mirror(url: 'http://cantbeblank',
+ update_status: 'finished',
+ enabled: true,
+ last_update_at: nil,
+ updated_at: 25.hours.ago)
+ project = mirror.project
+ project.pending_delete = true
+ project.save
+ mirror.reload
+
+ expect(mirror.sync).to be_nil
+ expect(mirror.valid?).to be_truthy
+ expect(mirror.update_status).to eq('finished')
+ end
+ end
+
+ def create_mirror(params)
+ project = FactoryBot.create(:project, :repository)
+ project.remote_mirrors.create!(params)
+ end
+end
diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb
index 630b9e0519f..4b736b02b7d 100644
--- a/spec/models/repository_spec.rb
+++ b/spec/models/repository_spec.rb
@@ -758,6 +758,38 @@ describe Repository do
end
end
+ describe '#async_remove_remote' do
+ before do
+ masterrev = repository.find_branch('master').dereferenced_target
+ create_remote_branch('joe', 'remote_branch', masterrev)
+ end
+
+ context 'when worker is scheduled successfully' do
+ before do
+ masterrev = repository.find_branch('master').dereferenced_target
+ create_remote_branch('remote_name', 'remote_branch', masterrev)
+
+ allow(RepositoryRemoveRemoteWorker).to receive(:perform_async).and_return('1234')
+ end
+
+ it 'returns job_id' do
+ expect(repository.async_remove_remote('joe')).to eq('1234')
+ end
+ end
+
+ context 'when worker does not schedule successfully' do
+ before do
+ allow(RepositoryRemoveRemoteWorker).to receive(:perform_async).and_return(nil)
+ end
+
+ it 'returns nil' do
+ expect(Rails.logger).to receive(:info).with("Remove remote job failed to create for #{project.id} with remote name joe.")
+
+ expect(repository.async_remove_remote('joe')).to be_nil
+ end
+ end
+ end
+
describe '#fetch_ref' do
let(:broken_repository) { create(:project, :broken_storage).repository }
@@ -2338,6 +2370,11 @@ describe Repository do
end
end
+ def create_remote_branch(remote_name, branch_name, target)
+ rugged = repository.rugged
+ rugged.references.create("refs/remotes/#{remote_name}/#{branch_name}", target.id)
+ end
+
describe '#ancestor?' do
let(:commit) { repository.commit }
let(:ancestor) { commit.parents.first }
diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb
index 90b7e7715a8..1c765ceac2f 100644
--- a/spec/models/wiki_page_spec.rb
+++ b/spec/models/wiki_page_spec.rb
@@ -1,7 +1,7 @@
require "spec_helper"
describe WikiPage do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :wiki_repo) }
let(:user) { project.owner }
let(:wiki) { ProjectWiki.new(project, user) }
diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb
index 9ae39f2ef44..082605827b7 100644
--- a/spec/requests/api/runner_spec.rb
+++ b/spec/requests/api/runner_spec.rb
@@ -1,11 +1,13 @@
require 'spec_helper'
-describe API::Runner do
+describe API::Runner, :clean_gitlab_redis_shared_state do
include StubGitlabCalls
+ include RedisHelpers
let(:registration_token) { 'abcdefg123456' }
before do
+ stub_feature_flags(ci_enable_live_trace: true)
stub_gitlab_calls
stub_application_setting(runners_registration_token: registration_token)
allow_any_instance_of(Ci::Runner).to receive(:cache_attributes)
@@ -882,6 +884,49 @@ describe API::Runner do
expect(response.status).to eq(403)
end
end
+
+ context 'when trace is patched' do
+ before do
+ patch_the_trace
+ end
+
+ it 'has valid trace' do
+ expect(response.status).to eq(202)
+ expect(job.reload.trace.raw).to eq 'BUILD TRACE appended appended'
+ end
+
+ context 'when redis data are flushed' do
+ before do
+ redis_shared_state_cleanup!
+ end
+
+ it 'has empty trace' do
+ expect(job.reload.trace.raw).to eq ''
+ end
+
+ context 'when we perform partial patch' do
+ before do
+ patch_the_trace('hello', headers.merge({ 'Content-Range' => "28-32/5" }))
+ end
+
+ it 'returns an error' do
+ expect(response.status).to eq(416)
+ expect(response.header['Range']).to eq('0-0')
+ end
+ end
+
+ context 'when we resend full trace' do
+ before do
+ patch_the_trace('BUILD TRACE appended appended hello', headers.merge({ 'Content-Range' => "0-34/35" }))
+ end
+
+ it 'succeeds with updating trace' do
+ expect(response.status).to eq(202)
+ expect(job.reload.trace.raw).to eq 'BUILD TRACE appended appended hello'
+ end
+ end
+ end
+ end
end
context 'when Runner makes a force-patch' do
@@ -898,7 +943,7 @@ describe API::Runner do
end
context 'when content-range start is too big' do
- let(:headers_with_range) { headers.merge({ 'Content-Range' => '15-20' }) }
+ let(:headers_with_range) { headers.merge({ 'Content-Range' => '15-20/6' }) }
it 'gets 416 error response with range headers' do
expect(response.status).to eq 416
@@ -908,7 +953,7 @@ describe API::Runner do
end
context 'when content-range start is too small' do
- let(:headers_with_range) { headers.merge({ 'Content-Range' => '8-20' }) }
+ let(:headers_with_range) { headers.merge({ 'Content-Range' => '8-20/13' }) }
it 'gets 416 error response with range headers' do
expect(response.status).to eq 416
diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb
index f8d5258a8d9..aca4aa40027 100644
--- a/spec/requests/api/search_spec.rb
+++ b/spec/requests/api/search_spec.rb
@@ -3,7 +3,7 @@ require 'spec_helper'
describe API::Search do
set(:user) { create(:user) }
set(:group) { create(:group) }
- set(:project) { create(:project, :public, name: 'awesome project', group: group) }
+ set(:project) { create(:project, :wiki_repo, :public, name: 'awesome project', group: group) }
set(:repo_project) { create(:project, :public, :repository, group: group) }
shared_examples 'response is correct' do |schema:, size: 1|
diff --git a/spec/requests/api/wikis_spec.rb b/spec/requests/api/wikis_spec.rb
index fb0806ff9f1..850ba696098 100644
--- a/spec/requests/api/wikis_spec.rb
+++ b/spec/requests/api/wikis_spec.rb
@@ -143,7 +143,7 @@ describe API::Wikis do
let(:url) { "/projects/#{project.id}/wikis" }
context 'when wiki is disabled' do
- let(:project) { create(:project, :wiki_disabled) }
+ let(:project) { create(:project, :wiki_repo, :wiki_disabled) }
context 'when user is guest' do
before do
@@ -175,7 +175,7 @@ describe API::Wikis do
end
context 'when wiki is available only for team members' do
- let(:project) { create(:project, :wiki_private) }
+ let(:project) { create(:project, :wiki_repo, :wiki_private) }
context 'when user is guest' do
before do
@@ -203,7 +203,7 @@ describe API::Wikis do
end
context 'when wiki is available for everyone with access' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :wiki_repo) }
context 'when user is guest' do
before do
@@ -236,7 +236,7 @@ describe API::Wikis do
let(:url) { "/projects/#{project.id}/wikis/#{page.slug}" }
context 'when wiki is disabled' do
- let(:project) { create(:project, :wiki_disabled) }
+ let(:project) { create(:project, :wiki_repo, :wiki_disabled) }
context 'when user is guest' do
before do
@@ -268,7 +268,7 @@ describe API::Wikis do
end
context 'when wiki is available only for team members' do
- let(:project) { create(:project, :wiki_private) }
+ let(:project) { create(:project, :wiki_repo, :wiki_private) }
context 'when user is guest' do
before do
@@ -311,7 +311,7 @@ describe API::Wikis do
end
context 'when wiki is available for everyone with access' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :wiki_repo) }
context 'when user is guest' do
before do
@@ -360,7 +360,7 @@ describe API::Wikis do
let(:url) { "/projects/#{project.id}/wikis" }
context 'when wiki is disabled' do
- let(:project) { create(:project, :wiki_disabled) }
+ let(:project) { create(:project, :wiki_disabled, :wiki_repo) }
context 'when user is guest' do
before do
@@ -390,7 +390,7 @@ describe API::Wikis do
end
context 'when wiki is available only for team members' do
- let(:project) { create(:project, :wiki_private) }
+ let(:project) { create(:project, :wiki_private, :wiki_repo) }
context 'when user is guest' do
before do
@@ -418,7 +418,7 @@ describe API::Wikis do
end
context 'when wiki is available for everyone with access' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :wiki_repo) }
context 'when user is guest' do
before do
@@ -452,7 +452,7 @@ describe API::Wikis do
let(:url) { "/projects/#{project.id}/wikis/#{page.slug}" }
context 'when wiki is disabled' do
- let(:project) { create(:project, :wiki_disabled) }
+ let(:project) { create(:project, :wiki_disabled, :wiki_repo) }
context 'when user is guest' do
before do
@@ -484,7 +484,7 @@ describe API::Wikis do
end
context 'when wiki is available only for team members' do
- let(:project) { create(:project, :wiki_private) }
+ let(:project) { create(:project, :wiki_private, :wiki_repo) }
context 'when user is guest' do
before do
@@ -528,7 +528,7 @@ describe API::Wikis do
end
context 'when wiki is available for everyone with access' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :wiki_repo) }
context 'when user is guest' do
before do
@@ -572,7 +572,7 @@ describe API::Wikis do
end
context 'when wiki belongs to a group project' do
- let(:project) { create(:project, namespace: group) }
+ let(:project) { create(:project, :wiki_repo, namespace: group) }
before do
put(api(url, user), payload)
@@ -587,7 +587,7 @@ describe API::Wikis do
let(:url) { "/projects/#{project.id}/wikis/#{page.slug}" }
context 'when wiki is disabled' do
- let(:project) { create(:project, :wiki_disabled) }
+ let(:project) { create(:project, :wiki_disabled, :wiki_repo) }
context 'when user is guest' do
before do
@@ -619,7 +619,7 @@ describe API::Wikis do
end
context 'when wiki is available only for team members' do
- let(:project) { create(:project, :wiki_private) }
+ let(:project) { create(:project, :wiki_private, :wiki_repo) }
context 'when user is guest' do
before do
@@ -651,7 +651,7 @@ describe API::Wikis do
end
context 'when wiki is available for everyone with access' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :wiki_repo) }
context 'when user is guest' do
before do
@@ -689,7 +689,7 @@ describe API::Wikis do
end
context 'when wiki belongs to a group project' do
- let(:project) { create(:project, namespace: group) }
+ let(:project) { create(:project, :wiki_repo, namespace: group) }
before do
delete(api(url, user))
diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb
index 5bc6031388e..e1cb7ed8110 100644
--- a/spec/services/ci/retry_build_service_spec.rb
+++ b/spec/services/ci/retry_build_service_spec.rb
@@ -32,7 +32,7 @@ describe Ci::RetryBuildService do
runner_id tag_taggings taggings tags trigger_request_id
user_id auto_canceled_by_id retried failure_reason
artifacts_file_store artifacts_metadata_store
- metadata].freeze
+ metadata trace_chunks].freeze
shared_examples 'build duplication' do
let(:another_pipeline) { create(:ci_empty_pipeline, project: project) }
diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb
index 26fdf8d4b24..35826de5814 100644
--- a/spec/services/git_push_service_spec.rb
+++ b/spec/services/git_push_service_spec.rb
@@ -14,6 +14,72 @@ describe GitPushService, services: true do
project.add_master(user)
end
+ describe 'with remote mirrors' do
+ let(:project) { create(:project, :repository, :remote_mirror) }
+
+ subject do
+ described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
+ end
+
+ context 'when remote mirror feature is enabled' do
+ it 'fails stuck remote mirrors' do
+ allow(project).to receive(:update_remote_mirrors).and_return(project.remote_mirrors)
+ expect(project).to receive(:mark_stuck_remote_mirrors_as_failed!)
+
+ subject.execute
+ end
+
+ it 'updates remote mirrors' do
+ expect(project).to receive(:update_remote_mirrors)
+
+ subject.execute
+ end
+ end
+
+ context 'when remote mirror feature is disabled' do
+ before do
+ stub_application_setting(mirror_available: false)
+ end
+
+ context 'with remote mirrors global setting overridden' do
+ before do
+ project.remote_mirror_available_overridden = true
+ end
+
+ it 'fails stuck remote mirrors' do
+ allow(project).to receive(:update_remote_mirrors).and_return(project.remote_mirrors)
+ expect(project).to receive(:mark_stuck_remote_mirrors_as_failed!)
+
+ subject.execute
+ end
+
+ it 'updates remote mirrors' do
+ expect(project).to receive(:update_remote_mirrors)
+
+ subject.execute
+ end
+ end
+
+ context 'without remote mirrors global setting overridden' do
+ before do
+ project.remote_mirror_available_overridden = false
+ end
+
+ it 'does not fails stuck remote mirrors' do
+ expect(project).not_to receive(:mark_stuck_remote_mirrors_as_failed!)
+
+ subject.execute
+ end
+
+ it 'does not updates remote mirrors' do
+ expect(project).not_to receive(:update_remote_mirrors)
+
+ subject.execute
+ end
+ end
+ end
+ end
+
describe 'Push branches' do
subject do
execute_service(project, user, oldrev, newrev, ref)
diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb
index b2c52214f48..b63f409579e 100644
--- a/spec/services/projects/destroy_service_spec.rb
+++ b/spec/services/projects/destroy_service_spec.rb
@@ -65,6 +65,19 @@ describe Projects::DestroyService do
Sidekiq::Testing.inline! { destroy_project(project, user, {}) }
end
+ context 'when has remote mirrors' do
+ let!(:project) do
+ create(:project, :repository, namespace: user.namespace).tap do |project|
+ project.remote_mirrors.create(url: 'http://test.com')
+ end
+ end
+ let!(:async) { true }
+
+ it 'destroys them' do
+ expect(RemoteMirror.count).to eq(0)
+ end
+ end
+
it_behaves_like 'deleting the project'
it 'invalidates personal_project_count cache' do
diff --git a/spec/services/projects/update_remote_mirror_service_spec.rb b/spec/services/projects/update_remote_mirror_service_spec.rb
new file mode 100644
index 00000000000..be09afd9f36
--- /dev/null
+++ b/spec/services/projects/update_remote_mirror_service_spec.rb
@@ -0,0 +1,355 @@
+require 'spec_helper'
+
+describe Projects::UpdateRemoteMirrorService do
+ let(:project) { create(:project, :repository) }
+ let(:remote_project) { create(:forked_project_with_submodules) }
+ let(:repository) { project.repository }
+ let(:raw_repository) { repository.raw }
+ let(:remote_mirror) { project.remote_mirrors.create!(url: remote_project.http_url_to_repo, enabled: true, only_protected_branches: false) }
+
+ subject { described_class.new(project, project.creator) }
+
+ describe "#execute", :skip_gitaly_mock do
+ before do
+ create_branch(repository, 'existing-branch')
+ allow(raw_repository).to receive(:remote_tags) do
+ generate_tags(repository, 'v1.0.0', 'v1.1.0')
+ end
+ allow(raw_repository).to receive(:push_remote_branches).and_return(true)
+ end
+
+ it "fetches the remote repository" do
+ expect(repository).to receive(:fetch_remote).with(remote_mirror.remote_name, no_tags: true) do
+ sync_remote(repository, remote_mirror.remote_name, local_branch_names)
+ end
+
+ subject.execute(remote_mirror)
+ end
+
+ it "succeeds" do
+ allow(repository).to receive(:fetch_remote) { sync_remote(repository, remote_mirror.remote_name, local_branch_names) }
+
+ result = subject.execute(remote_mirror)
+
+ expect(result[:status]).to eq(:success)
+ end
+
+ describe 'Syncing branches' do
+ it "push all the branches the first time" do
+ allow(repository).to receive(:fetch_remote)
+
+ expect(raw_repository).to receive(:push_remote_branches).with(remote_mirror.remote_name, local_branch_names)
+
+ subject.execute(remote_mirror)
+ end
+
+ it "does not push anything is remote is up to date" do
+ allow(repository).to receive(:fetch_remote) { sync_remote(repository, remote_mirror.remote_name, local_branch_names) }
+
+ expect(raw_repository).not_to receive(:push_remote_branches)
+
+ subject.execute(remote_mirror)
+ end
+
+ it "sync new branches" do
+ # call local_branch_names early so it is not called after the new branch has been created
+ current_branches = local_branch_names
+ allow(repository).to receive(:fetch_remote) { sync_remote(repository, remote_mirror.remote_name, current_branches) }
+ create_branch(repository, 'my-new-branch')
+
+ expect(raw_repository).to receive(:push_remote_branches).with(remote_mirror.remote_name, ['my-new-branch'])
+
+ subject.execute(remote_mirror)
+ end
+
+ it "sync updated branches" do
+ allow(repository).to receive(:fetch_remote) do
+ sync_remote(repository, remote_mirror.remote_name, local_branch_names)
+ update_branch(repository, 'existing-branch')
+ end
+
+ expect(raw_repository).to receive(:push_remote_branches).with(remote_mirror.remote_name, ['existing-branch'])
+
+ subject.execute(remote_mirror)
+ end
+
+ context 'when push only protected branches option is set' do
+ let(:unprotected_branch_name) { 'existing-branch' }
+ let(:protected_branch_name) do
+ project.repository.branch_names.find { |n| n != unprotected_branch_name }
+ end
+ let!(:protected_branch) do
+ create(:protected_branch, project: project, name: protected_branch_name)
+ end
+
+ before do
+ project.reload
+ remote_mirror.only_protected_branches = true
+ end
+
+ it "sync updated protected branches" do
+ allow(repository).to receive(:fetch_remote) do
+ sync_remote(repository, remote_mirror.remote_name, local_branch_names)
+ update_branch(repository, protected_branch_name)
+ end
+
+ expect(raw_repository).to receive(:push_remote_branches).with(remote_mirror.remote_name, [protected_branch_name])
+
+ subject.execute(remote_mirror)
+ end
+
+ it 'does not sync unprotected branches' do
+ allow(repository).to receive(:fetch_remote) do
+ sync_remote(repository, remote_mirror.remote_name, local_branch_names)
+ update_branch(repository, unprotected_branch_name)
+ end
+
+ expect(raw_repository).not_to receive(:push_remote_branches).with(remote_mirror.remote_name, [unprotected_branch_name])
+
+ subject.execute(remote_mirror)
+ end
+ end
+
+ context 'when branch exists in local and remote repo' do
+ context 'when it has diverged' do
+ it 'syncs branches' do
+ allow(repository).to receive(:fetch_remote) do
+ sync_remote(repository, remote_mirror.remote_name, local_branch_names)
+ update_remote_branch(repository, remote_mirror.remote_name, 'markdown')
+ end
+
+ expect(raw_repository).to receive(:push_remote_branches).with(remote_mirror.remote_name, ['markdown'])
+
+ subject.execute(remote_mirror)
+ end
+ end
+ end
+
+ describe 'for delete' do
+ context 'when branch exists in local and remote repo' do
+ it 'deletes the branch from remote repo' do
+ allow(repository).to receive(:fetch_remote) do
+ sync_remote(repository, remote_mirror.remote_name, local_branch_names)
+ delete_branch(repository, 'existing-branch')
+ end
+
+ expect(raw_repository).to receive(:delete_remote_branches).with(remote_mirror.remote_name, ['existing-branch'])
+
+ subject.execute(remote_mirror)
+ end
+ end
+
+ context 'when push only protected branches option is set' do
+ before do
+ remote_mirror.only_protected_branches = true
+ end
+
+ context 'when branch exists in local and remote repo' do
+ let!(:protected_branch_name) { local_branch_names.first }
+
+ before do
+ create(:protected_branch, project: project, name: protected_branch_name)
+ project.reload
+ end
+
+ it 'deletes the protected branch from remote repo' do
+ allow(repository).to receive(:fetch_remote) do
+ sync_remote(repository, remote_mirror.remote_name, local_branch_names)
+ delete_branch(repository, protected_branch_name)
+ end
+
+ expect(raw_repository).not_to receive(:delete_remote_branches).with(remote_mirror.remote_name, [protected_branch_name])
+
+ subject.execute(remote_mirror)
+ end
+
+ it 'does not delete the unprotected branch from remote repo' do
+ allow(repository).to receive(:fetch_remote) do
+ sync_remote(repository, remote_mirror.remote_name, local_branch_names)
+ delete_branch(repository, 'existing-branch')
+ end
+
+ expect(raw_repository).not_to receive(:delete_remote_branches).with(remote_mirror.remote_name, ['existing-branch'])
+
+ subject.execute(remote_mirror)
+ end
+ end
+
+ context 'when branch only exists on remote repo' do
+ let!(:protected_branch_name) { 'remote-branch' }
+
+ before do
+ create(:protected_branch, project: project, name: protected_branch_name)
+ end
+
+ context 'when it has diverged' do
+ it 'does not delete the remote branch' do
+ allow(repository).to receive(:fetch_remote) do
+ sync_remote(repository, remote_mirror.remote_name, local_branch_names)
+
+ rev = repository.find_branch('markdown').dereferenced_target
+ create_remote_branch(repository, remote_mirror.remote_name, 'remote-branch', rev.id)
+ end
+
+ expect(raw_repository).not_to receive(:delete_remote_branches)
+
+ subject.execute(remote_mirror)
+ end
+ end
+
+ context 'when it has not diverged' do
+ it 'deletes the remote branch' do
+ allow(repository).to receive(:fetch_remote) do
+ sync_remote(repository, remote_mirror.remote_name, local_branch_names)
+
+ masterrev = repository.find_branch('master').dereferenced_target
+ create_remote_branch(repository, remote_mirror.remote_name, protected_branch_name, masterrev.id)
+ end
+
+ expect(raw_repository).to receive(:delete_remote_branches).with(remote_mirror.remote_name, [protected_branch_name])
+
+ subject.execute(remote_mirror)
+ end
+ end
+ end
+ end
+
+ context 'when branch only exists on remote repo' do
+ context 'when it has diverged' do
+ it 'does not delete the remote branch' do
+ allow(repository).to receive(:fetch_remote) do
+ sync_remote(repository, remote_mirror.remote_name, local_branch_names)
+
+ rev = repository.find_branch('markdown').dereferenced_target
+ create_remote_branch(repository, remote_mirror.remote_name, 'remote-branch', rev.id)
+ end
+
+ expect(raw_repository).not_to receive(:delete_remote_branches)
+
+ subject.execute(remote_mirror)
+ end
+ end
+
+ context 'when it has not diverged' do
+ it 'deletes the remote branch' do
+ allow(repository).to receive(:fetch_remote) do
+ sync_remote(repository, remote_mirror.remote_name, local_branch_names)
+
+ masterrev = repository.find_branch('master').dereferenced_target
+ create_remote_branch(repository, remote_mirror.remote_name, 'remote-branch', masterrev.id)
+ end
+
+ expect(raw_repository).to receive(:delete_remote_branches).with(remote_mirror.remote_name, ['remote-branch'])
+
+ subject.execute(remote_mirror)
+ end
+ end
+ end
+ end
+ end
+
+ describe 'Syncing tags' do
+ before do
+ allow(repository).to receive(:fetch_remote) { sync_remote(repository, remote_mirror.remote_name, local_branch_names) }
+ end
+
+ context 'when there are not tags to push' do
+ it 'does not try to push tags' do
+ allow(repository).to receive(:remote_tags) { {} }
+ allow(repository).to receive(:tags) { [] }
+
+ expect(repository).not_to receive(:push_tags)
+
+ subject.execute(remote_mirror)
+ end
+ end
+
+ context 'when there are some tags to push' do
+ it 'pushes tags to remote' do
+ allow(raw_repository).to receive(:remote_tags) { {} }
+
+ expect(raw_repository).to receive(:push_remote_branches).with(remote_mirror.remote_name, ['v1.0.0', 'v1.1.0'])
+
+ subject.execute(remote_mirror)
+ end
+ end
+
+ context 'when there are some tags to delete' do
+ it 'deletes tags from remote' do
+ remote_tags = generate_tags(repository, 'v1.0.0', 'v1.1.0')
+ allow(raw_repository).to receive(:remote_tags) { remote_tags }
+
+ repository.rm_tag(create(:user), 'v1.0.0')
+
+ expect(raw_repository).to receive(:delete_remote_branches).with(remote_mirror.remote_name, ['v1.0.0'])
+
+ subject.execute(remote_mirror)
+ end
+ end
+ end
+ end
+
+ def create_branch(repository, branch_name)
+ rugged = repository.rugged
+ masterrev = repository.find_branch('master').dereferenced_target
+ parentrev = repository.commit(masterrev).parent_id
+
+ rugged.references.create("refs/heads/#{branch_name}", parentrev)
+
+ repository.expire_branches_cache
+ end
+
+ def create_remote_branch(repository, remote_name, branch_name, source_id)
+ rugged = repository.rugged
+
+ rugged.references.create("refs/remotes/#{remote_name}/#{branch_name}", source_id)
+ end
+
+ def sync_remote(repository, remote_name, local_branch_names)
+ rugged = repository.rugged
+
+ local_branch_names.each do |branch|
+ target = repository.find_branch(branch).try(:dereferenced_target)
+ rugged.references.create("refs/remotes/#{remote_name}/#{branch}", target.id) if target
+ end
+ end
+
+ def update_remote_branch(repository, remote_name, branch)
+ rugged = repository.rugged
+ masterrev = repository.find_branch('master').dereferenced_target.id
+
+ rugged.references.create("refs/remotes/#{remote_name}/#{branch}", masterrev, force: true)
+ repository.expire_branches_cache
+ end
+
+ def update_branch(repository, branch)
+ rugged = repository.rugged
+ masterrev = repository.find_branch('master').dereferenced_target.id
+
+ # Updated existing branch
+ rugged.references.create("refs/heads/#{branch}", masterrev, force: true)
+ repository.expire_branches_cache
+ end
+
+ def delete_branch(repository, branch)
+ rugged = repository.rugged
+
+ rugged.references.delete("refs/heads/#{branch}")
+ repository.expire_branches_cache
+ end
+
+ def generate_tags(repository, *tag_names)
+ tag_names.each_with_object([]) do |name, tags|
+ tag = repository.find_tag(name)
+ target = tag.try(:target)
+ target_commit = tag.try(:dereferenced_target)
+ tags << Gitlab::Git::Tag.new(repository.raw_repository, name, target, target_commit)
+ end
+ end
+
+ def local_branch_names
+ branch_names = repository.branches.map(&:name)
+ # we want the protected branch to be pushed first
+ branch_names.unshift(branch_names.delete('master'))
+ end
+end
diff --git a/spec/services/test_hooks/project_service_spec.rb b/spec/services/test_hooks/project_service_spec.rb
index 28dfa9cf59c..962b9f40c4f 100644
--- a/spec/services/test_hooks/project_service_spec.rb
+++ b/spec/services/test_hooks/project_service_spec.rb
@@ -170,6 +170,7 @@ describe TestHooks::ProjectService do
end
context 'wiki_page_events' do
+ let(:project) { create(:project, :wiki_repo) }
let(:trigger) { 'wiki_page_events' }
let(:trigger_key) { :wiki_page_hooks }
diff --git a/spec/services/wiki_pages/create_service_spec.rb b/spec/services/wiki_pages/create_service_spec.rb
index b270194d9b8..259f445247e 100644
--- a/spec/services/wiki_pages/create_service_spec.rb
+++ b/spec/services/wiki_pages/create_service_spec.rb
@@ -1,7 +1,7 @@
require 'spec_helper'
describe WikiPages::CreateService do
- let(:project) { create(:project) }
+ let(:project) { create(:project, :wiki_repo) }
let(:user) { create(:user) }
let(:opts) do
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index cc61cd7d838..b4fc596a751 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -86,6 +86,7 @@ RSpec.configure do |config|
config.include WaitForRequests, :js
config.include LiveDebugger, :js
config.include MigrationsHelpers, :migration
+ config.include RedisHelpers
if ENV['CI']
# This includes the first try, i.e. tests will be run 4 times before failing.
@@ -146,21 +147,27 @@ RSpec.configure do |config|
end
config.around(:each, :clean_gitlab_redis_cache) do |example|
- Gitlab::Redis::Cache.with(&:flushall)
+ redis_cache_cleanup!
example.run
- Gitlab::Redis::Cache.with(&:flushall)
+ redis_cache_cleanup!
end
config.around(:each, :clean_gitlab_redis_shared_state) do |example|
- Gitlab::Redis::SharedState.with(&:flushall)
- Sidekiq.redis(&:flushall)
+ redis_shared_state_cleanup!
example.run
- Gitlab::Redis::SharedState.with(&:flushall)
- Sidekiq.redis(&:flushall)
+ redis_shared_state_cleanup!
+ end
+
+ config.around(:each, :clean_gitlab_redis_queues) do |example|
+ redis_queues_cleanup!
+
+ example.run
+
+ redis_queues_cleanup!
end
# The :each scope runs "inside" the example, so this hook ensures the DB is in the
diff --git a/spec/support/chunked_io/chunked_io_helpers.rb b/spec/support/chunked_io/chunked_io_helpers.rb
new file mode 100644
index 00000000000..fec1f951563
--- /dev/null
+++ b/spec/support/chunked_io/chunked_io_helpers.rb
@@ -0,0 +1,11 @@
+module ChunkedIOHelpers
+ def sample_trace_raw
+ @sample_trace_raw ||= File.read(expand_fixture_path('trace/sample_trace'))
+ .force_encoding(Encoding::BINARY)
+ end
+
+ def stub_buffer_size(size)
+ stub_const('Ci::BuildTraceChunk::CHUNK_SIZE', size)
+ stub_const('Gitlab::Ci::Trace::ChunkedIO::CHUNK_SIZE', size)
+ end
+end
diff --git a/spec/support/redis/redis_helpers.rb b/spec/support/redis/redis_helpers.rb
new file mode 100644
index 00000000000..0457e8487d8
--- /dev/null
+++ b/spec/support/redis/redis_helpers.rb
@@ -0,0 +1,18 @@
+module RedisHelpers
+ # config/README.md
+
+ # Usage: performance enhancement
+ def redis_cache_cleanup!
+ Gitlab::Redis::Cache.with(&:flushall)
+ end
+
+ # Usage: SideKiq, Mailroom, CI Runner, Workhorse, push services
+ def redis_queues_cleanup!
+ Gitlab::Redis::Queues.with(&:flushall)
+ end
+
+ # Usage: session state, rate limiting
+ def redis_shared_state_cleanup!
+ Gitlab::Redis::SharedState.with(&:flushall)
+ end
+end
diff --git a/spec/support/shared_examples/ci_trace_shared_examples.rb b/spec/support/shared_examples/ci_trace_shared_examples.rb
new file mode 100644
index 00000000000..21c6f3c829f
--- /dev/null
+++ b/spec/support/shared_examples/ci_trace_shared_examples.rb
@@ -0,0 +1,741 @@
+shared_examples_for 'common trace features' do
+ describe '#html' do
+ before do
+ trace.set("12\n34")
+ end
+
+ it "returns formatted html" do
+ expect(trace.html).to eq("12<br>34")
+ end
+
+ it "returns last line of formatted html" do
+ expect(trace.html(last_lines: 1)).to eq("34")
+ end
+ end
+
+ describe '#raw' do
+ before do
+ trace.set("12\n34")
+ end
+
+ it "returns raw output" do
+ expect(trace.raw).to eq("12\n34")
+ end
+
+ it "returns last line of raw output" do
+ expect(trace.raw(last_lines: 1)).to eq("34")
+ end
+ end
+
+ describe '#extract_coverage' do
+ let(:regex) { '\(\d+.\d+\%\) covered' }
+
+ context 'matching coverage' do
+ before do
+ trace.set('Coverage 1033 / 1051 LOC (98.29%) covered')
+ end
+
+ it "returns valid coverage" do
+ expect(trace.extract_coverage(regex)).to eq("98.29")
+ end
+ end
+
+ context 'no coverage' do
+ before do
+ trace.set('No coverage')
+ end
+
+ it 'returs nil' do
+ expect(trace.extract_coverage(regex)).to be_nil
+ end
+ end
+ end
+
+ describe '#extract_sections' do
+ let(:log) { 'No sections' }
+ let(:sections) { trace.extract_sections }
+
+ before do
+ trace.set(log)
+ end
+
+ context 'no sections' do
+ it 'returs []' do
+ expect(trace.extract_sections).to eq([])
+ end
+ end
+
+ context 'multiple sections available' do
+ let(:log) { File.read(expand_fixture_path('trace/trace_with_sections')) }
+ let(:sections_data) do
+ [
+ { name: 'prepare_script', lines: 2, duration: 3.seconds },
+ { name: 'get_sources', lines: 4, duration: 1.second },
+ { name: 'restore_cache', lines: 0, duration: 0.seconds },
+ { name: 'download_artifacts', lines: 0, duration: 0.seconds },
+ { name: 'build_script', lines: 2, duration: 1.second },
+ { name: 'after_script', lines: 0, duration: 0.seconds },
+ { name: 'archive_cache', lines: 0, duration: 0.seconds },
+ { name: 'upload_artifacts', lines: 0, duration: 0.seconds }
+ ]
+ end
+
+ it "returns valid sections" do
+ expect(sections).not_to be_empty
+ expect(sections.size).to eq(sections_data.size),
+ "expected #{sections_data.size} sections, got #{sections.size}"
+
+ buff = StringIO.new(log)
+ sections.each_with_index do |s, i|
+ expected = sections_data[i]
+
+ expect(s[:name]).to eq(expected[:name])
+ expect(s[:date_end] - s[:date_start]).to eq(expected[:duration])
+
+ buff.seek(s[:byte_start], IO::SEEK_SET)
+ length = s[:byte_end] - s[:byte_start]
+ lines = buff.read(length).count("\n")
+ expect(lines).to eq(expected[:lines])
+ end
+ end
+ end
+
+ context 'logs contains "section_start"' do
+ let(:log) { "section_start:1506417476:a_section\r\033[0Klooks like a section_start:invalid\nsection_end:1506417477:a_section\r\033[0K"}
+
+ it "returns only one section" do
+ expect(sections).not_to be_empty
+ expect(sections.size).to eq(1)
+
+ section = sections[0]
+ expect(section[:name]).to eq('a_section')
+ expect(section[:byte_start]).not_to eq(section[:byte_end]), "got an empty section"
+ end
+ end
+
+ context 'missing section_end' do
+ let(:log) { "section_start:1506417476:a_section\r\033[0KSome logs\nNo section_end\n"}
+
+ it "returns no sections" do
+ expect(sections).to be_empty
+ end
+ end
+
+ context 'missing section_start' do
+ let(:log) { "Some logs\nNo section_start\nsection_end:1506417476:a_section\r\033[0K"}
+
+ it "returns no sections" do
+ expect(sections).to be_empty
+ end
+ end
+
+ context 'inverted section_start section_end' do
+ let(:log) { "section_end:1506417476:a_section\r\033[0Klooks like a section_start:invalid\nsection_start:1506417477:a_section\r\033[0K"}
+
+ it "returns no sections" do
+ expect(sections).to be_empty
+ end
+ end
+ end
+
+ describe '#set' do
+ before do
+ trace.set("12")
+ end
+
+ it "returns trace" do
+ expect(trace.raw).to eq("12")
+ end
+
+ context 'overwrite trace' do
+ before do
+ trace.set("34")
+ end
+
+ it "returns new trace" do
+ expect(trace.raw).to eq("34")
+ end
+ end
+
+ context 'runners token' do
+ let(:token) { 'my_secret_token' }
+
+ before do
+ build.project.update(runners_token: token)
+ trace.set(token)
+ end
+
+ it "hides token" do
+ expect(trace.raw).not_to include(token)
+ end
+ end
+
+ context 'hides build token' do
+ let(:token) { 'my_secret_token' }
+
+ before do
+ build.update(token: token)
+ trace.set(token)
+ end
+
+ it "hides token" do
+ expect(trace.raw).not_to include(token)
+ end
+ end
+ end
+
+ describe '#append' do
+ before do
+ trace.set("1234")
+ end
+
+ it "returns correct trace" do
+ expect(trace.append("56", 4)).to eq(6)
+ expect(trace.raw).to eq("123456")
+ end
+
+ context 'tries to append trace at different offset' do
+ it "fails with append" do
+ expect(trace.append("56", 2)).to eq(4)
+ expect(trace.raw).to eq("1234")
+ end
+ end
+
+ context 'runners token' do
+ let(:token) { 'my_secret_token' }
+
+ before do
+ build.project.update(runners_token: token)
+ trace.append(token, 0)
+ end
+
+ it "hides token" do
+ expect(trace.raw).not_to include(token)
+ end
+ end
+
+ context 'build token' do
+ let(:token) { 'my_secret_token' }
+
+ before do
+ build.update(token: token)
+ trace.append(token, 0)
+ end
+
+ it "hides token" do
+ expect(trace.raw).not_to include(token)
+ end
+ end
+ end
+end
+
+shared_examples_for 'trace with disabled live trace feature' do
+ it_behaves_like 'common trace features'
+
+ describe '#read' do
+ shared_examples 'read successfully with IO' do
+ it 'yields with source' do
+ trace.read do |stream|
+ expect(stream).to be_a(Gitlab::Ci::Trace::Stream)
+ expect(stream.stream).to be_a(IO)
+ end
+ end
+ end
+
+ shared_examples 'read successfully with StringIO' do
+ it 'yields with source' do
+ trace.read do |stream|
+ expect(stream).to be_a(Gitlab::Ci::Trace::Stream)
+ expect(stream.stream).to be_a(StringIO)
+ end
+ end
+ end
+
+ shared_examples 'failed to read' do
+ it 'yields without source' do
+ trace.read do |stream|
+ expect(stream).to be_a(Gitlab::Ci::Trace::Stream)
+ expect(stream.stream).to be_nil
+ end
+ end
+ end
+
+ context 'when trace artifact exists' do
+ before do
+ create(:ci_job_artifact, :trace, job: build)
+ end
+
+ it_behaves_like 'read successfully with IO'
+ end
+
+ context 'when current_path (with project_id) exists' do
+ before do
+ expect(trace).to receive(:default_path) { expand_fixture_path('trace/sample_trace') }
+ end
+
+ it_behaves_like 'read successfully with IO'
+ end
+
+ context 'when current_path (with project_ci_id) exists' do
+ before do
+ expect(trace).to receive(:deprecated_path) { expand_fixture_path('trace/sample_trace') }
+ end
+
+ it_behaves_like 'read successfully with IO'
+ end
+
+ context 'when db trace exists' do
+ before do
+ build.send(:write_attribute, :trace, "data")
+ end
+
+ it_behaves_like 'read successfully with StringIO'
+ end
+
+ context 'when no sources exist' do
+ it_behaves_like 'failed to read'
+ end
+ end
+
+ describe 'trace handling' do
+ subject { trace.exist? }
+
+ context 'trace does not exist' do
+ it { expect(trace.exist?).to be(false) }
+ end
+
+ context 'when trace artifact exists' do
+ before do
+ create(:ci_job_artifact, :trace, job: build)
+ end
+
+ it { is_expected.to be_truthy }
+
+ context 'when the trace artifact has been erased' do
+ before do
+ trace.erase!
+ end
+
+ it { is_expected.to be_falsy }
+
+ it 'removes associations' do
+ expect(Ci::JobArtifact.exists?(job_id: build.id, file_type: :trace)).to be_falsy
+ end
+ end
+ end
+
+ context 'new trace path is used' do
+ before do
+ trace.send(:ensure_directory)
+
+ File.open(trace.send(:default_path), "w") do |file|
+ file.write("data")
+ end
+ end
+
+ it "trace exist" do
+ expect(trace.exist?).to be(true)
+ end
+
+ it "can be erased" do
+ trace.erase!
+ expect(trace.exist?).to be(false)
+ end
+ end
+
+ context 'deprecated path' do
+ let(:path) { trace.send(:deprecated_path) }
+
+ context 'with valid ci_id' do
+ before do
+ build.project.update(ci_id: 1000)
+
+ FileUtils.mkdir_p(File.dirname(path))
+
+ File.open(path, "w") do |file|
+ file.write("data")
+ end
+ end
+
+ it "trace exist" do
+ expect(trace.exist?).to be(true)
+ end
+
+ it "can be erased" do
+ trace.erase!
+ expect(trace.exist?).to be(false)
+ end
+ end
+
+ context 'without valid ci_id' do
+ it "does not return deprecated path" do
+ expect(path).to be_nil
+ end
+ end
+ end
+
+ context 'stored in database' do
+ before do
+ build.send(:write_attribute, :trace, "data")
+ end
+
+ it "trace exist" do
+ expect(trace.exist?).to be(true)
+ end
+
+ it "can be erased" do
+ trace.erase!
+ expect(trace.exist?).to be(false)
+ end
+
+ it "returns database data" do
+ expect(trace.raw).to eq("data")
+ end
+ end
+ end
+
+ describe '#archive!' do
+ subject { trace.archive! }
+
+ shared_examples 'archive trace file' do
+ it do
+ expect { subject }.to change { Ci::JobArtifact.count }.by(1)
+
+ build.reload
+ expect(build.trace.exist?).to be_truthy
+ expect(build.job_artifacts_trace.file.exists?).to be_truthy
+ expect(build.job_artifacts_trace.file.filename).to eq('job.log')
+ expect(File.exist?(src_path)).to be_falsy
+ expect(src_checksum)
+ .to eq(Digest::SHA256.file(build.job_artifacts_trace.file.path).hexdigest)
+ expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum)
+ end
+ end
+
+ shared_examples 'source trace file stays intact' do |error:|
+ it do
+ expect { subject }.to raise_error(error)
+
+ build.reload
+ expect(build.trace.exist?).to be_truthy
+ expect(build.job_artifacts_trace).to be_nil
+ expect(File.exist?(src_path)).to be_truthy
+ end
+ end
+
+ shared_examples 'archive trace in database' do
+ it do
+ expect { subject }.to change { Ci::JobArtifact.count }.by(1)
+
+ build.reload
+ expect(build.trace.exist?).to be_truthy
+ expect(build.job_artifacts_trace.file.exists?).to be_truthy
+ expect(build.job_artifacts_trace.file.filename).to eq('job.log')
+ expect(build.old_trace).to be_nil
+ expect(src_checksum)
+ .to eq(Digest::SHA256.file(build.job_artifacts_trace.file.path).hexdigest)
+ expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum)
+ end
+ end
+
+ shared_examples 'source trace in database stays intact' do |error:|
+ it do
+ expect { subject }.to raise_error(error)
+
+ build.reload
+ expect(build.trace.exist?).to be_truthy
+ expect(build.job_artifacts_trace).to be_nil
+ expect(build.old_trace).to eq(trace_content)
+ end
+ end
+
+ context 'when job does not have trace artifact' do
+ context 'when trace file stored in default path' do
+ let!(:build) { create(:ci_build, :success, :trace_live) }
+ let!(:src_path) { trace.read { |s| s.path } }
+ let!(:src_checksum) { Digest::SHA256.file(src_path).hexdigest }
+
+ it_behaves_like 'archive trace file'
+
+ context 'when failed to create clone file' do
+ before do
+ allow(IO).to receive(:copy_stream).and_return(0)
+ end
+
+ it_behaves_like 'source trace file stays intact', error: Gitlab::Ci::Trace::ArchiveError
+ end
+
+ context 'when failed to create job artifact record' do
+ before do
+ allow_any_instance_of(Ci::JobArtifact).to receive(:save).and_return(false)
+ allow_any_instance_of(Ci::JobArtifact).to receive_message_chain(:errors, :full_messages)
+ .and_return(%w[Error Error])
+ end
+
+ it_behaves_like 'source trace file stays intact', error: ActiveRecord::RecordInvalid
+ end
+ end
+
+ context 'when trace is stored in database' do
+ let(:build) { create(:ci_build, :success) }
+ let(:trace_content) { 'Sample trace' }
+ let(:src_checksum) { Digest::SHA256.hexdigest(trace_content) }
+
+ before do
+ build.update_column(:trace, trace_content)
+ end
+
+ it_behaves_like 'archive trace in database'
+
+ context 'when failed to create clone file' do
+ before do
+ allow(IO).to receive(:copy_stream).and_return(0)
+ end
+
+ it_behaves_like 'source trace in database stays intact', error: Gitlab::Ci::Trace::ArchiveError
+ end
+
+ context 'when failed to create job artifact record' do
+ before do
+ allow_any_instance_of(Ci::JobArtifact).to receive(:save).and_return(false)
+ allow_any_instance_of(Ci::JobArtifact).to receive_message_chain(:errors, :full_messages)
+ .and_return(%w[Error Error])
+ end
+
+ it_behaves_like 'source trace in database stays intact', error: ActiveRecord::RecordInvalid
+ end
+
+ context 'when there is a validation error on Ci::Build' do
+ before do
+ allow_any_instance_of(Ci::Build).to receive(:save).and_return(false)
+ allow_any_instance_of(Ci::Build).to receive_message_chain(:errors, :full_messages)
+ .and_return(%w[Error Error])
+ end
+
+ context "when erase old trace with 'save'" do
+ before do
+ build.send(:write_attribute, :trace, nil)
+ build.save
+ end
+
+ it 'old trace is not deleted' do
+ build.reload
+ expect(build.trace.raw).to eq(trace_content)
+ end
+ end
+
+ it_behaves_like 'archive trace in database'
+ end
+ end
+ end
+
+ context 'when job has trace artifact' do
+ before do
+ create(:ci_job_artifact, :trace, job: build)
+ end
+
+ it 'does not archive' do
+ expect_any_instance_of(described_class).not_to receive(:archive_stream!)
+ expect { subject }.to raise_error('Already archived')
+ expect(build.job_artifacts_trace.file.exists?).to be_truthy
+ end
+ end
+
+ context 'when job is not finished yet' do
+ let!(:build) { create(:ci_build, :running, :trace_live) }
+
+ it 'does not archive' do
+ expect_any_instance_of(described_class).not_to receive(:archive_stream!)
+ expect { subject }.to raise_error('Job is not finished yet')
+ expect(build.trace.exist?).to be_truthy
+ end
+ end
+ end
+end
+
+shared_examples_for 'trace with enabled live trace feature' do
+ it_behaves_like 'common trace features'
+
+ describe '#read' do
+ shared_examples 'read successfully with IO' do
+ it 'yields with source' do
+ trace.read do |stream|
+ expect(stream).to be_a(Gitlab::Ci::Trace::Stream)
+ expect(stream.stream).to be_a(IO)
+ end
+ end
+ end
+
+ shared_examples 'read successfully with ChunkedIO' do
+ it 'yields with source' do
+ trace.read do |stream|
+ expect(stream).to be_a(Gitlab::Ci::Trace::Stream)
+ expect(stream.stream).to be_a(Gitlab::Ci::Trace::ChunkedIO)
+ end
+ end
+ end
+
+ shared_examples 'failed to read' do
+ it 'yields without source' do
+ trace.read do |stream|
+ expect(stream).to be_a(Gitlab::Ci::Trace::Stream)
+ expect(stream.stream).to be_nil
+ end
+ end
+ end
+
+ context 'when trace artifact exists' do
+ before do
+ create(:ci_job_artifact, :trace, job: build)
+ end
+
+ it_behaves_like 'read successfully with IO'
+ end
+
+ context 'when live trace exists' do
+ before do
+ Gitlab::Ci::Trace::ChunkedIO.new(build) do |stream|
+ stream.write('abc')
+ end
+ end
+
+ it_behaves_like 'read successfully with ChunkedIO'
+ end
+
+ context 'when no sources exist' do
+ it_behaves_like 'failed to read'
+ end
+ end
+
+ describe 'trace handling' do
+ subject { trace.exist? }
+
+ context 'trace does not exist' do
+ it { expect(trace.exist?).to be(false) }
+ end
+
+ context 'when trace artifact exists' do
+ before do
+ create(:ci_job_artifact, :trace, job: build)
+ end
+
+ it { is_expected.to be_truthy }
+
+ context 'when the trace artifact has been erased' do
+ before do
+ trace.erase!
+ end
+
+ it { is_expected.to be_falsy }
+
+ it 'removes associations' do
+ expect(Ci::JobArtifact.exists?(job_id: build.id, file_type: :trace)).to be_falsy
+ end
+ end
+ end
+
+ context 'stored in live trace' do
+ before do
+ Gitlab::Ci::Trace::ChunkedIO.new(build) do |stream|
+ stream.write('abc')
+ end
+ end
+
+ it "trace exist" do
+ expect(trace.exist?).to be(true)
+ end
+
+ it "can be erased" do
+ trace.erase!
+ expect(trace.exist?).to be(false)
+ expect(Ci::BuildTraceChunk.where(build: build)).not_to be_exist
+ end
+
+ it "returns live trace data" do
+ expect(trace.raw).to eq("abc")
+ end
+ end
+ end
+
+ describe '#archive!' do
+ subject { trace.archive! }
+
+ shared_examples 'archive trace file in ChunkedIO' do
+ it do
+ expect { subject }.to change { Ci::JobArtifact.count }.by(1)
+
+ build.reload
+ expect(build.trace.exist?).to be_truthy
+ expect(build.job_artifacts_trace.file.exists?).to be_truthy
+ expect(build.job_artifacts_trace.file.filename).to eq('job.log')
+ expect(Ci::BuildTraceChunk.where(build: build)).not_to be_exist
+ expect(src_checksum)
+ .to eq(Digest::SHA256.file(build.job_artifacts_trace.file.path).hexdigest)
+ expect(build.job_artifacts_trace.file_sha256).to eq(src_checksum)
+ end
+ end
+
+ shared_examples 'source trace in ChunkedIO stays intact' do |error:|
+ it do
+ expect { subject }.to raise_error(error)
+
+ build.reload
+ expect(build.trace.exist?).to be_truthy
+ expect(build.job_artifacts_trace).to be_nil
+ Gitlab::Ci::Trace::ChunkedIO.new(build) do |stream|
+ expect(stream.read).to eq(trace_raw)
+ end
+ end
+ end
+
+ context 'when job does not have trace artifact' do
+ context 'when trace is stored in ChunkedIO' do
+ let!(:build) { create(:ci_build, :success, :trace_live) }
+ let!(:trace_raw) { build.trace.raw }
+ let!(:src_checksum) { Digest::SHA256.hexdigest(trace_raw) }
+
+ it_behaves_like 'archive trace file in ChunkedIO'
+
+ context 'when failed to create clone file' do
+ before do
+ allow(IO).to receive(:copy_stream).and_return(0)
+ end
+
+ it_behaves_like 'source trace in ChunkedIO stays intact', error: Gitlab::Ci::Trace::ArchiveError
+ end
+
+ context 'when failed to create job artifact record' do
+ before do
+ allow_any_instance_of(Ci::JobArtifact).to receive(:save).and_return(false)
+ allow_any_instance_of(Ci::JobArtifact).to receive_message_chain(:errors, :full_messages)
+ .and_return(%w[Error Error])
+ end
+
+ it_behaves_like 'source trace in ChunkedIO stays intact', error: ActiveRecord::RecordInvalid
+ end
+ end
+ end
+
+ context 'when job has trace artifact' do
+ before do
+ create(:ci_job_artifact, :trace, job: build)
+ end
+
+ it 'does not archive' do
+ expect_any_instance_of(described_class).not_to receive(:archive_stream!)
+ expect { subject }.to raise_error('Already archived')
+ expect(build.job_artifacts_trace.file.exists?).to be_truthy
+ end
+ end
+
+ context 'when job is not finished yet' do
+ let!(:build) { create(:ci_build, :running, :trace_live) }
+
+ it 'does not archive' do
+ expect_any_instance_of(described_class).not_to receive(:archive_stream!)
+ expect { subject }.to raise_error('Job is not finished yet')
+ expect(build.trace.exist?).to be_truthy
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/fast_destroy_all.rb b/spec/support/shared_examples/fast_destroy_all.rb
new file mode 100644
index 00000000000..5448ddcfe33
--- /dev/null
+++ b/spec/support/shared_examples/fast_destroy_all.rb
@@ -0,0 +1,38 @@
+shared_examples_for 'fast destroyable' do
+ describe 'Forbid #destroy and #destroy_all' do
+ it 'does not delete database rows and associted external data' do
+ expect(external_data_counter).to be > 0
+ expect(subjects.count).to be > 0
+
+ expect { subjects.first.destroy }.to raise_error('`destroy` and `destroy_all` are forbbiden. Please use `fast_destroy_all`')
+ expect { subjects.destroy_all }.to raise_error('`destroy` and `destroy_all` are forbbiden. Please use `fast_destroy_all`')
+
+ expect(subjects.count).to be > 0
+ expect(external_data_counter).to be > 0
+ end
+ end
+
+ describe '.fast_destroy_all' do
+ it 'deletes database rows and associted external data' do
+ expect(external_data_counter).to be > 0
+ expect(subjects.count).to be > 0
+
+ expect { subjects.fast_destroy_all }.not_to raise_error
+
+ expect(subjects.count).to eq(0)
+ expect(external_data_counter).to eq(0)
+ end
+ end
+
+ describe '.use_fast_destroy' do
+ it 'performs cascading delete with fast_destroy_all' do
+ expect(external_data_counter).to be > 0
+ expect(subjects.count).to be > 0
+
+ expect { parent.destroy }.not_to raise_error
+
+ expect(subjects.count).to eq(0)
+ expect(external_data_counter).to eq(0)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb b/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb
index 07bc3a51fd8..2228e872926 100644
--- a/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb
+++ b/spec/support/shared_examples/slack_mattermost_notifications_shared_examples.rb
@@ -35,7 +35,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do
describe "#execute" do
let(:user) { create(:user) }
- let(:project) { create(:project, :repository) }
+ let(:project) { create(:project, :repository, :wiki_repo) }
let(:username) { 'slack_username' }
let(:channel) { 'slack_channel' }
let(:issue_service_options) { { title: 'Awesome issue', description: 'please fix' } }
diff --git a/spec/workers/admin_email_worker_spec.rb b/spec/workers/admin_email_worker_spec.rb
new file mode 100644
index 00000000000..27687f069ea
--- /dev/null
+++ b/spec/workers/admin_email_worker_spec.rb
@@ -0,0 +1,41 @@
+require 'spec_helper'
+
+describe AdminEmailWorker do
+ subject(:worker) { described_class.new }
+
+ describe '.perform' do
+ it 'does not attempt to send repository check mail when they are disabled' do
+ stub_application_setting(repository_checks_enabled: false)
+
+ expect(worker).not_to receive(:send_repository_check_mail)
+
+ worker.perform
+ end
+
+ context 'repository_checks enabled' do
+ before do
+ stub_application_setting(repository_checks_enabled: true)
+ end
+
+ it 'checks if repository check mail should be sent' do
+ expect(worker).to receive(:send_repository_check_mail)
+
+ worker.perform
+ end
+
+ it 'does not send mail when there are no failed repos' do
+ expect(RepositoryCheckMailer).not_to receive(:notify)
+
+ worker.perform
+ end
+
+ it 'send mail when there is a failed repo' do
+ create(:project, last_repository_check_failed: true, last_repository_check_at: Date.yesterday)
+
+ expect(RepositoryCheckMailer).to receive(:notify).and_return(spy)
+
+ worker.perform
+ end
+ end
+ end
+end
diff --git a/spec/workers/repository_check/batch_worker_spec.rb b/spec/workers/repository_check/batch_worker_spec.rb
index 850b8cd8f5c..6cd27d2fafb 100644
--- a/spec/workers/repository_check/batch_worker_spec.rb
+++ b/spec/workers/repository_check/batch_worker_spec.rb
@@ -31,8 +31,8 @@ describe RepositoryCheck::BatchWorker do
it 'does nothing when repository checks are disabled' do
create(:project, created_at: 1.week.ago)
- current_settings = double('settings', repository_checks_enabled: false)
- expect(subject).to receive(:current_settings) { current_settings }
+
+ stub_application_setting(repository_checks_enabled: false)
expect(subject.perform).to eq(nil)
end
diff --git a/spec/workers/repository_check/single_repository_worker_spec.rb b/spec/workers/repository_check/single_repository_worker_spec.rb
index 1d9bbf2ca62..a021235aed6 100644
--- a/spec/workers/repository_check/single_repository_worker_spec.rb
+++ b/spec/workers/repository_check/single_repository_worker_spec.rb
@@ -2,44 +2,60 @@ require 'spec_helper'
require 'fileutils'
describe RepositoryCheck::SingleRepositoryWorker do
- subject { described_class.new }
+ subject(:worker) { described_class.new }
- it 'passes when the project has no push events' do
- project = create(:project_empty_repo, :wiki_disabled)
+ it 'skips when the project has no push events' do
+ project = create(:project, :repository, :wiki_disabled)
project.events.destroy_all
- break_repo(project)
+ break_project(project)
- subject.perform(project.id)
+ expect(worker).not_to receive(:git_fsck)
+
+ worker.perform(project.id)
expect(project.reload.last_repository_check_failed).to eq(false)
end
it 'fails when the project has push events and a broken repository' do
- project = create(:project_empty_repo)
+ project = create(:project, :repository)
create_push_event(project)
- break_repo(project)
+ break_project(project)
- subject.perform(project.id)
+ worker.perform(project.id)
expect(project.reload.last_repository_check_failed).to eq(true)
end
+ it 'succeeds when the project repo is valid' do
+ project = create(:project, :repository, :wiki_disabled)
+ create_push_event(project)
+
+ expect(worker).to receive(:git_fsck).and_call_original
+
+ expect do
+ worker.perform(project.id)
+ end.to change { project.reload.last_repository_check_at }
+
+ expect(project.reload.last_repository_check_failed).to eq(false)
+ end
+
it 'fails if the wiki repository is broken' do
- project = create(:project_empty_repo, :wiki_enabled)
+ project = create(:project, :repository, :wiki_enabled)
project.create_wiki
+ create_push_event(project)
# Test sanity: everything should be fine before the wiki repo is broken
- subject.perform(project.id)
+ worker.perform(project.id)
expect(project.reload.last_repository_check_failed).to eq(false)
break_wiki(project)
- subject.perform(project.id)
+ worker.perform(project.id)
expect(project.reload.last_repository_check_failed).to eq(true)
end
it 'skips wikis when disabled' do
- project = create(:project_empty_repo, :wiki_disabled)
+ project = create(:project, :wiki_disabled)
# Make sure the test would fail if the wiki repo was checked
break_wiki(project)
@@ -49,8 +65,8 @@ describe RepositoryCheck::SingleRepositoryWorker do
end
it 'creates missing wikis' do
- project = create(:project_empty_repo, :wiki_enabled)
- FileUtils.rm_rf(wiki_path(project))
+ project = create(:project, :wiki_enabled)
+ Gitlab::Shell.new.rm_directory(project.repository_storage, project.wiki.path)
subject.perform(project.id)
@@ -58,34 +74,39 @@ describe RepositoryCheck::SingleRepositoryWorker do
end
it 'does not create a wiki if the main repo does not exist at all' do
- project = create(:project_empty_repo)
- create_push_event(project)
- FileUtils.rm_rf(project.repository.path_to_repo)
- FileUtils.rm_rf(wiki_path(project))
+ project = create(:project, :repository)
+ Gitlab::Shell.new.rm_directory(project.repository_storage, project.path)
+ Gitlab::Shell.new.rm_directory(project.repository_storage, project.wiki.path)
subject.perform(project.id)
- expect(File.exist?(wiki_path(project))).to eq(false)
+ expect(Gitlab::Shell.new.exists?(project.repository_storage, project.wiki.path)).to eq(false)
end
- def break_wiki(project)
- objects_dir = wiki_path(project) + '/objects'
+ def create_push_event(project)
+ project.events.create(action: Event::PUSHED, author_id: create(:user).id)
+ end
- # Replace the /objects directory with a file so that the repo is
- # invalid, _and_ 'git init' cannot fix it.
- FileUtils.rm_rf(objects_dir)
- FileUtils.touch(objects_dir) if File.directory?(wiki_path(project))
+ def break_wiki(project)
+ break_repo(wiki_path(project))
end
def wiki_path(project)
project.wiki.repository.path_to_repo
end
- def create_push_event(project)
- project.events.create(action: Event::PUSHED, author_id: create(:user).id)
+ def break_project(project)
+ break_repo(project.repository.path_to_repo)
end
- def break_repo(project)
- FileUtils.rm_rf(File.join(project.repository.path_to_repo, 'objects'))
+ def break_repo(repo)
+ # Create or replace blob ffffffffffffffffffffffffffffffffffffffff with an empty file
+ # This will make the repo invalid, _and_ 'git init' cannot fix it.
+ path = File.join(repo, 'objects', 'ff')
+ file = File.join(path, 'ffffffffffffffffffffffffffffffffffffff')
+
+ FileUtils.mkdir_p(path)
+ FileUtils.rm_f(file)
+ FileUtils.touch(file)
end
end
diff --git a/spec/workers/repository_remove_remote_worker_spec.rb b/spec/workers/repository_remove_remote_worker_spec.rb
new file mode 100644
index 00000000000..f22d7c1d073
--- /dev/null
+++ b/spec/workers/repository_remove_remote_worker_spec.rb
@@ -0,0 +1,50 @@
+require 'rails_helper'
+
+describe RepositoryRemoveRemoteWorker do
+ subject(:worker) { described_class.new }
+
+ describe '#perform' do
+ let(:remote_name) { 'joe'}
+ let!(:project) { create(:project, :repository) }
+
+ context 'when it cannot obtain lease' do
+ it 'logs error' do
+ allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain) { nil }
+
+ expect_any_instance_of(Repository).not_to receive(:remove_remote)
+ expect(worker).to receive(:log_error).with('Cannot obtain an exclusive lease. There must be another instance already in execution.')
+
+ worker.perform(project.id, remote_name)
+ end
+ end
+
+ context 'when it gets the lease' do
+ before do
+ allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return(true)
+ end
+
+ context 'when project does not exist' do
+ it 'returns nil' do
+ expect(worker.perform(-1, 'remote_name')).to be_nil
+ end
+ end
+
+ context 'when project exists' do
+ it 'removes remote from repository' do
+ masterrev = project.repository.find_branch('master').dereferenced_target
+
+ create_remote_branch(remote_name, 'remote_branch', masterrev)
+
+ expect_any_instance_of(Repository).to receive(:remove_remote).with(remote_name).and_call_original
+
+ worker.perform(project.id, remote_name)
+ end
+ end
+ end
+ end
+
+ def create_remote_branch(remote_name, branch_name, target)
+ rugged = project.repository.rugged
+ rugged.references.create("refs/remotes/#{remote_name}/#{branch_name}", target.id)
+ end
+end
diff --git a/spec/workers/repository_update_remote_mirror_worker_spec.rb b/spec/workers/repository_update_remote_mirror_worker_spec.rb
new file mode 100644
index 00000000000..152ba2509b9
--- /dev/null
+++ b/spec/workers/repository_update_remote_mirror_worker_spec.rb
@@ -0,0 +1,84 @@
+require 'rails_helper'
+
+describe RepositoryUpdateRemoteMirrorWorker do
+ subject { described_class.new }
+
+ let(:remote_mirror) { create(:project, :repository, :remote_mirror).remote_mirrors.first }
+ let(:scheduled_time) { Time.now - 5.minutes }
+
+ around do |example|
+ Timecop.freeze(Time.now) { example.run }
+ end
+
+ describe '#perform' do
+ context 'with status none' do
+ before do
+ remote_mirror.update_attributes(update_status: 'none')
+ end
+
+ it 'sets status as finished when update remote mirror service executes successfully' do
+ expect_any_instance_of(Projects::UpdateRemoteMirrorService).to receive(:execute).with(remote_mirror).and_return(status: :success)
+
+ expect { subject.perform(remote_mirror.id, Time.now) }.to change { remote_mirror.reload.update_status }.to('finished')
+ end
+
+ it 'sets status as failed when update remote mirror service executes with errors' do
+ error_message = 'fail!'
+
+ expect_any_instance_of(Projects::UpdateRemoteMirrorService).to receive(:execute).with(remote_mirror).and_return(status: :error, message: error_message)
+ expect do
+ subject.perform(remote_mirror.id, Time.now)
+ end.to raise_error(RepositoryUpdateRemoteMirrorWorker::UpdateError, error_message)
+
+ expect(remote_mirror.reload.update_status).to eq('failed')
+ end
+
+ it 'does nothing if last_update_started_at is higher than the time the job was scheduled in' do
+ remote_mirror.update_attributes(last_update_started_at: Time.now)
+
+ expect_any_instance_of(RemoteMirror).to receive(:updated_since?).with(scheduled_time).and_return(true)
+ expect_any_instance_of(Projects::UpdateRemoteMirrorService).not_to receive(:execute).with(remote_mirror)
+
+ expect(subject.perform(remote_mirror.id, scheduled_time)).to be_nil
+ end
+ end
+
+ context 'with unexpected error' do
+ it 'marks mirror as failed' do
+ allow_any_instance_of(Projects::UpdateRemoteMirrorService).to receive(:execute).with(remote_mirror).and_raise(RuntimeError)
+
+ expect do
+ subject.perform(remote_mirror.id, Time.now)
+ end.to raise_error(RepositoryUpdateRemoteMirrorWorker::UpdateError)
+ expect(remote_mirror.reload.update_status).to eq('failed')
+ end
+ end
+
+ context 'with another worker already running' do
+ before do
+ remote_mirror.update_attributes(update_status: 'started')
+ end
+
+ it 'raises RemoteMirrorUpdateAlreadyInProgressError' do
+ expect do
+ subject.perform(remote_mirror.id, Time.now)
+ end.to raise_error(RepositoryUpdateRemoteMirrorWorker::UpdateAlreadyInProgressError)
+ end
+ end
+
+ context 'with status failed' do
+ before do
+ remote_mirror.update_attributes(update_status: 'failed')
+ end
+
+ it 'sets status as finished if last_update_started_at is higher than the time the job was scheduled in' do
+ remote_mirror.update_attributes(last_update_started_at: Time.now)
+
+ expect_any_instance_of(RemoteMirror).to receive(:updated_since?).with(scheduled_time).and_return(false)
+ expect_any_instance_of(Projects::UpdateRemoteMirrorService).to receive(:execute).with(remote_mirror).and_return(status: :success)
+
+ expect { subject.perform(remote_mirror.id, scheduled_time) }.to change { remote_mirror.reload.update_status }.to('finished')
+ end
+ end
+ end
+end
diff --git a/yarn.lock b/yarn.lock
index 1e6ffa5f524..33e129a8e51 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8091,9 +8091,9 @@ undefsafe@^2.0.1:
dependencies:
debug "^2.2.0"
-underscore@^1.8.3:
- version "1.8.3"
- resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022"
+underscore@^1.9.0:
+ version "1.9.0"
+ resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.0.tgz#31dbb314cfcc88f169cd3692d9149d81a00a73e4"
underscore@~1.7.0:
version "1.7.0"