summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-04-20 18:38:24 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-04-20 18:38:24 +0000
commit983a0bba5d2a042c4a3bbb22432ec192c7501d82 (patch)
treeb153cd387c14ba23bd5a07514c7c01fddf6a78a0 /app
parenta2bddee2cdb38673df0e004d5b32d9f77797de64 (diff)
downloadgitlab-ce-983a0bba5d2a042c4a3bbb22432ec192c7501d82.tar.gz
Add latest changes from gitlab-org/gitlab@12-10-stable-ee
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js8
-rw-r--r--app/assets/javascripts/blob/components/blob_edit_content.vue9
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_key_field.vue169
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js29
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue100
-rw-r--r--app/assets/javascripts/ci_variable_list/constants.js5
-rw-r--r--app/assets/javascripts/clusters/services/application_state_machine.js3
-rw-r--r--app/assets/javascripts/contextual_sidebar.js4
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js6
-rw-r--r--app/assets/javascripts/diffs/components/diff_table_cell.vue8
-rw-r--r--app/assets/javascripts/diffs/constants.js1
-rw-r--r--app/assets/javascripts/diffs/store/getters_versions_dropdowns.js20
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js17
-rw-r--r--app/assets/javascripts/diffs/store/utils.js10
-rw-r--r--app/assets/javascripts/filterable_list.js4
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js43
-rw-r--r--app/assets/javascripts/groups/components/groups.vue2
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_app.vue115
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_form.vue54
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_progress.vue66
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_setup.vue23
-rw-r--r--app/assets/javascripts/jira_import/index.js3
-rw-r--r--app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql14
-rw-r--r--app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql12
-rw-r--r--app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql11
-rw-r--r--app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql7
-rw-r--r--app/assets/javascripts/jira_import/utils.js10
-rw-r--r--app/assets/javascripts/lazy_loader.js8
-rw-r--r--app/assets/javascripts/lib/utils/keycodes.js7
-rw-r--r--app/assets/javascripts/lib/utils/unit_format/index.js19
-rw-r--r--app/assets/javascripts/logs/constants.js2
-rw-r--r--app/assets/javascripts/main.js12
-rw-r--r--app/assets/javascripts/monitoring/components/charts/annotations.js97
-rw-r--r--app/assets/javascripts/monitoring/components/charts/empty_chart.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/charts/options.js6
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue38
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue312
-rw-r--r--app/assets/javascripts/monitoring/components/panel_type.vue10
-rw-r--r--app/assets/javascripts/monitoring/constants.js28
-rw-r--r--app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql29
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js29
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js35
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue177
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js9
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/payload_previewer.js (renamed from app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js)4
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue44
-rw-r--r--app/assets/javascripts/projects/default_project_templates.js4
-rw-r--r--app/assets/javascripts/registry/explorer/components/project_policy_alert.vue1
-rw-r--r--app/assets/javascripts/registry/explorer/constants.js45
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue168
-rw-r--r--app/assets/javascripts/registry/explorer/stores/actions.js8
-rw-r--r--app/assets/javascripts/registry/explorer/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/registry/explorer/stores/mutations.js13
-rw-r--r--app/assets/javascripts/releases/components/app_edit.vue12
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue12
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue4
-rw-r--r--app/assets/javascripts/repository/components/table/parent_row.vue2
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue2
-rw-r--r--app/assets/javascripts/repository/graphql.js2
-rw-r--r--app/assets/javascripts/repository/index.js4
-rw-r--r--app/assets/javascripts/repository/router.js2
-rw-r--r--app/assets/javascripts/snippet/snippet_edit.js13
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue211
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_edit.vue2
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue14
-rw-r--r--app/assets/javascripts/snippets/index.js9
-rw-r--r--app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql8
-rw-r--r--app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql8
-rw-r--r--app/assets/javascripts/static_site_editor/components/invalid_content_message.vue29
-rw-r--r--app/assets/javascripts/static_site_editor/components/publish_toolbar.vue16
-rw-r--r--app/assets/javascripts/static_site_editor/components/saved_changes_message.vue20
-rw-r--r--app/assets/javascripts/static_site_editor/components/static_site_editor.vue85
-rw-r--r--app/assets/javascripts/static_site_editor/components/submit_changes_error.vue24
-rw-r--r--app/assets/javascripts/static_site_editor/index.js11
-rw-r--r--app/assets/javascripts/static_site_editor/store/actions.js7
-rw-r--r--app/assets/javascripts/static_site_editor/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/static_site_editor/store/mutations.js7
-rw-r--r--app/assets/javascripts/static_site_editor/store/state.js2
-rw-r--r--app/assets/javascripts/user_popovers.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue178
-rw-r--r--app/assets/javascripts/vue_shared/components/clone_dropdown.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/form/title.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue40
-rw-r--r--app/assets/stylesheets/components/related_items_list.scss25
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss4
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss6
-rw-r--r--app/assets/stylesheets/utilities.scss5
-rw-r--r--app/controllers/admin/runners_controller.rb10
-rw-r--r--app/controllers/application_controller.rb2
-rw-r--r--app/controllers/clusters/applications_controller.rb2
-rw-r--r--app/controllers/concerns/enforces_two_factor_authentication.rb8
-rw-r--r--app/controllers/concerns/integrations_actions.rb4
-rw-r--r--app/controllers/dashboard/projects_controller.rb16
-rw-r--r--app/controllers/explore/projects_controller.rb17
-rw-r--r--app/controllers/groups/milestones_controller.rb3
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb4
-rw-r--r--app/controllers/projects/forks_controller.rb19
-rw-r--r--app/controllers/projects/import/jira_controller.rb13
-rw-r--r--app/controllers/projects/issues_controller.rb11
-rw-r--r--app/controllers/projects/merge_requests_controller.rb7
-rw-r--r--app/controllers/projects/milestones_controller.rb3
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb3
-rw-r--r--app/controllers/projects_controller.rb4
-rw-r--r--app/controllers/repositories/git_http_controller.rb12
-rw-r--r--app/controllers/users_controller.rb7
-rw-r--r--app/finders/autocomplete/move_to_project_finder.rb3
-rw-r--r--app/finders/autocomplete/routes_finder.rb47
-rw-r--r--app/finders/metrics/dashboards/annotations_finder.rb42
-rw-r--r--app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb28
-rw-r--r--app/graphql/types/merge_request_type.rb2
-rw-r--r--app/graphql/types/metrics/dashboard_type.rb5
-rw-r--r--app/graphql/types/metrics/dashboards/annotation_type.rb31
-rw-r--r--app/graphql/types/project_type.rb2
-rw-r--r--app/helpers/preferences_helper.rb34
-rw-r--r--app/helpers/projects_helper.rb2
-rw-r--r--app/mailers/emails/issues.rb14
-rw-r--r--app/mailers/previews/notify_preview.rb4
-rw-r--r--app/models/ci/bridge.rb1
-rw-r--r--app/models/ci/build.rb19
-rw-r--r--app/models/ci/job_artifact.rb15
-rw-r--r--app/models/ci/processable.rb6
-rw-r--r--app/models/ci/runner.rb6
-rw-r--r--app/models/clusters/applications/fluentd.rb101
-rw-r--r--app/models/clusters/applications/ingress.rb11
-rw-r--r--app/models/clusters/cluster.rb4
-rw-r--r--app/models/concerns/ci/has_ref.rb2
-rw-r--r--app/models/concerns/ci/pipeline_delegator.rb20
-rw-r--r--app/models/concerns/issuable.rb21
-rw-r--r--app/models/concerns/notification_branch_selection.rb14
-rw-r--r--app/models/concerns/project_features_compatibility.rb4
-rw-r--r--app/models/diff_discussion.rb1
-rw-r--r--app/models/diff_note_position.rb2
-rw-r--r--app/models/group.rb2
-rw-r--r--app/models/import_failure.rb7
-rw-r--r--app/models/jira_import_state.rb17
-rw-r--r--app/models/lfs_object.rb13
-rw-r--r--app/models/merge_request.rb22
-rw-r--r--app/models/merge_request_diff.rb19
-rw-r--r--app/models/metrics/dashboard/annotation.rb5
-rw-r--r--app/models/note.rb2
-rw-r--r--app/models/project.rb19
-rw-r--r--app/models/project_feature.rb17
-rw-r--r--app/models/project_import_state.rb4
-rw-r--r--app/models/project_services/chat_notification_service.rb10
-rw-r--r--app/models/project_services/discord_service.rb2
-rw-r--r--app/models/project_services/emails_on_push_service.rb2
-rw-r--r--app/models/project_services/hangouts_chat_service.rb2
-rw-r--r--app/models/project_services/issue_tracker_service.rb2
-rw-r--r--app/models/project_services/microsoft_teams_service.rb2
-rw-r--r--app/models/project_services/pipelines_email_service.rb2
-rw-r--r--app/models/project_services/prometheus_service.rb6
-rw-r--r--app/models/project_services/unify_circuit_service.rb2
-rw-r--r--app/models/resource_label_event.rb2
-rw-r--r--app/models/resource_milestone_event.rb6
-rw-r--r--app/models/route.rb4
-rw-r--r--app/models/terraform/state.rb15
-rw-r--r--app/models/user.rb17
-rw-r--r--app/models/user_type_enums.rb2
-rw-r--r--app/policies/global_policy.rb7
-rw-r--r--app/policies/group_policy.rb1
-rw-r--r--app/presenters/ci/pipeline_presenter.rb14
-rw-r--r--app/serializers/analytics_summary_entity.rb8
-rw-r--r--app/serializers/cluster_application_entity.rb3
-rw-r--r--app/serializers/discussion_entity.rb15
-rw-r--r--app/serializers/merge_request_basic_entity.rb2
-rw-r--r--app/serializers/merge_request_poll_cached_widget_entity.rb2
-rw-r--r--app/serializers/merge_request_serializer.rb4
-rw-r--r--app/serializers/route_entity.rb8
-rw-r--r--app/serializers/route_serializer.rb5
-rw-r--r--app/services/auto_merge/base_service.rb8
-rw-r--r--app/services/auto_merge/merge_when_pipeline_succeeds_service.rb4
-rw-r--r--app/services/auto_merge_service.rb37
-rw-r--r--app/services/clusters/applications/base_service.rb12
-rw-r--r--app/services/concerns/deploy_token_methods.rb8
-rw-r--r--app/services/emails/destroy_service.rb2
-rw-r--r--app/services/git/branch_push_service.rb2
-rw-r--r--app/services/git/process_ref_changes_service.rb8
-rw-r--r--app/services/groups/deploy_tokens/create_service.rb6
-rw-r--r--app/services/groups/import_export/import_service.rb10
-rw-r--r--app/services/groups/transfer_service.rb22
-rw-r--r--app/services/issues/export_csv_service.rb77
-rw-r--r--app/services/jira_import/start_import_service.rb8
-rw-r--r--app/services/merge_requests/merge_orchestration_service.rb40
-rw-r--r--app/services/merge_requests/pushed_branches_service.rb32
-rw-r--r--app/services/merge_requests/update_service.rb19
-rw-r--r--app/services/metrics/dashboard/transient_embed_service.rb5
-rw-r--r--app/services/personal_access_tokens/create_service.rb31
-rw-r--r--app/services/pod_logs/base_service.rb6
-rw-r--r--app/services/pod_logs/elasticsearch_service.rb21
-rw-r--r--app/services/pod_logs/kubernetes_service.rb19
-rw-r--r--app/services/projects/deploy_tokens/create_service.rb6
-rw-r--r--app/services/resources/create_access_token_service.rb111
-rw-r--r--app/services/snippets/create_service.rb4
-rw-r--r--app/services/terraform/remote_state_handler.rb77
-rw-r--r--app/services/users/build_service.rb12
-rw-r--r--app/uploaders/records_uploads.rb23
-rw-r--r--app/uploaders/terraform/state_uploader.rb2
-rw-r--r--app/views/admin/runners/show.html.haml2
-rw-r--r--app/views/ci/variables/_index.html.haml2
-rw-r--r--app/views/layouts/_page.html.haml3
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml2
-rw-r--r--app/views/notify/issues_csv_email.html.haml9
-rw-r--r--app/views/notify/issues_csv_email.text.erb5
-rw-r--r--app/views/projects/_flash_messages.html.haml2
-rw-r--r--app/views/projects/_home_panel.html.haml1
-rw-r--r--app/views/projects/commits/_commits.html.haml9
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml40
-rw-r--r--app/views/projects/import/jira/show.html.haml7
-rw-r--r--app/views/projects/issues/_nav_btns.html.haml4
-rw-r--r--app/views/projects/issues/export_csv/_button.html.haml4
-rw-r--r--app/views/projects/issues/export_csv/_modal.html.haml22
-rw-r--r--app/views/projects/services/_form.html.haml9
-rw-r--r--app/views/projects/services/prometheus/_help.html.haml6
-rw-r--r--app/views/projects/settings/operations/_prometheus.html.haml6
-rw-r--r--app/views/shared/projects/_project.html.haml4
-rw-r--r--app/views/shared/runners/_form.html.haml11
-rw-r--r--app/views/shared/snippets/_form.html.haml112
-rw-r--r--app/workers/all_queues.yml9
-rw-r--r--app/workers/concerns/cronjob_queue.rb10
-rw-r--r--app/workers/create_commit_signature_worker.rb13
-rw-r--r--app/workers/expire_pipeline_cache_worker.rb4
-rw-r--r--app/workers/export_csv_worker.rb21
-rw-r--r--app/workers/gitlab/jira_import/stage/finish_import_worker.rb2
-rw-r--r--app/workers/project_daily_statistics_worker.rb1
228 files changed, 3286 insertions, 1162 deletions
diff --git a/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js b/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js
new file mode 100644
index 00000000000..b4803be4d52
--- /dev/null
+++ b/app/assets/javascripts/admin/application_settings/setup_metrics_and_profiling.js
@@ -0,0 +1,8 @@
+import PayloadPreviewer from '~/pages/admin/application_settings/payload_previewer';
+
+export default () => {
+ new PayloadPreviewer(
+ document.querySelector('.js-usage-ping-payload-trigger'),
+ document.querySelector('.js-usage-ping-payload'),
+ ).init();
+};
diff --git a/app/assets/javascripts/blob/components/blob_edit_content.vue b/app/assets/javascripts/blob/components/blob_edit_content.vue
index 9a30ed93330..056b4ea4aa8 100644
--- a/app/assets/javascripts/blob/components/blob_edit_content.vue
+++ b/app/assets/javascripts/blob/components/blob_edit_content.vue
@@ -1,5 +1,6 @@
<script>
import { initEditorLite } from '~/blob/utils';
+import { debounce } from 'lodash';
export default {
props: {
@@ -32,16 +33,14 @@ export default {
});
},
methods: {
- triggerFileChange() {
+ triggerFileChange: debounce(function debouncedFileChange() {
this.$emit('input', this.editor.getValue());
- },
+ }, 250),
},
};
</script>
<template>
<div class="file-content code">
- <pre id="editor" ref="editor" data-editor-loading @focusout="triggerFileChange">{{
- value
- }}</pre>
+ <pre id="editor" ref="editor" data-editor-loading @keyup="triggerFileChange">{{ value }}</pre>
</div>
</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue
new file mode 100644
index 00000000000..f5c2cc57f3f
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue
@@ -0,0 +1,169 @@
+<script>
+import { uniqueId } from 'lodash';
+import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui';
+
+export default {
+ name: 'CiKeyField',
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ },
+ model: {
+ prop: 'value',
+ event: 'input',
+ },
+ props: {
+ tokenList: {
+ type: Array,
+ required: true,
+ },
+ value: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ results: [],
+ arrowCounter: -1,
+ userDismissedResults: false,
+ suggestionsId: uniqueId('token-suggestions-'),
+ };
+ },
+ computed: {
+ showAutocomplete() {
+ return this.showSuggestions ? 'off' : 'on';
+ },
+ showSuggestions() {
+ return this.results.length > 0;
+ },
+ },
+ mounted() {
+ document.addEventListener('click', this.handleClickOutside);
+ },
+ destroyed() {
+ document.removeEventListener('click', this.handleClickOutside);
+ },
+ methods: {
+ closeSuggestions() {
+ this.results = [];
+ this.arrowCounter = -1;
+ },
+ handleClickOutside(event) {
+ if (!this.$el.contains(event.target)) {
+ this.closeSuggestions();
+ }
+ },
+ onArrowDown() {
+ const newCount = this.arrowCounter + 1;
+
+ if (newCount >= this.results.length) {
+ this.arrowCounter = 0;
+ return;
+ }
+
+ this.arrowCounter = newCount;
+ },
+ onArrowUp() {
+ const newCount = this.arrowCounter - 1;
+
+ if (newCount < 0) {
+ this.arrowCounter = this.results.length - 1;
+ return;
+ }
+
+ this.arrowCounter = newCount;
+ },
+ onEnter() {
+ const currentToken = this.results[this.arrowCounter] || this.value;
+ this.selectToken(currentToken);
+ },
+ onEsc() {
+ if (!this.showSuggestions) {
+ this.$emit('input', '');
+ }
+ this.closeSuggestions();
+ this.userDismissedResults = true;
+ },
+ onEntry(value) {
+ this.$emit('input', value);
+ this.userDismissedResults = false;
+
+ // short circuit so that we don't false match on empty string
+ if (value.length < 1) {
+ this.closeSuggestions();
+ return;
+ }
+
+ const filteredTokens = this.tokenList.filter(token =>
+ token.toLowerCase().includes(value.toLowerCase()),
+ );
+
+ if (filteredTokens.length) {
+ this.openSuggestions(filteredTokens);
+ } else {
+ this.closeSuggestions();
+ }
+ },
+ openSuggestions(filteredResults) {
+ this.results = filteredResults;
+ },
+ selectToken(value) {
+ this.$emit('input', value);
+ this.closeSuggestions();
+ this.$emit('key-selected');
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="dropdown position-relative" role="combobox" aria-owns="token-suggestions">
+ <gl-form-group :label="__('Key')" label-for="ci-variable-key">
+ <gl-form-input
+ id="ci-variable-key"
+ :value="value"
+ type="text"
+ role="searchbox"
+ class="form-control pl-2 js-env-input"
+ :autocomplete="showAutocomplete"
+ aria-autocomplete="list"
+ aria-controls="token-suggestions"
+ aria-haspopup="listbox"
+ :aria-expanded="showSuggestions"
+ data-qa-selector="ci_variable_key_field"
+ @input="onEntry"
+ @keydown.down="onArrowDown"
+ @keydown.up="onArrowUp"
+ @keydown.enter.prevent="onEnter"
+ @keydown.esc.stop="onEsc"
+ @keydown.tab="closeSuggestions"
+ />
+ </gl-form-group>
+
+ <div
+ v-show="showSuggestions && !userDismissedResults"
+ id="ci-variable-dropdown"
+ class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width"
+ :class="{ 'd-block': showSuggestions }"
+ >
+ <div class="dropdown-content">
+ <ul :id="suggestionsId">
+ <li
+ v-for="(result, i) in results"
+ :key="i"
+ role="option"
+ :class="{ 'gl-bg-gray-100': i === arrowCounter }"
+ :aria-selected="i === arrowCounter"
+ >
+ <gl-button tabindex="-1" class="btn-transparent pl-2" @click="selectToken(result)">{{
+ result
+ }}</gl-button>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js
new file mode 100644
index 00000000000..9022bf51514
--- /dev/null
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js
@@ -0,0 +1,29 @@
+import { __ } from '~/locale';
+
+import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY } from '../constants';
+
+export const awsTokens = {
+ [AWS_ACCESS_KEY_ID]: {
+ name: AWS_ACCESS_KEY_ID,
+ /* Checks for exactly twenty characters that match key.
+ Based on greps suggested by Amazon at:
+ https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/
+ */
+ validation: val => /^[A-Za-z0-9]{20}$/.test(val),
+ invalidMessage: __('This variable does not match the expected pattern.'),
+ },
+ [AWS_DEFAULT_REGION]: {
+ name: AWS_DEFAULT_REGION,
+ },
+ [AWS_SECRET_ACCESS_KEY]: {
+ name: AWS_SECRET_ACCESS_KEY,
+ /* Checks for exactly forty characters that match secret.
+ Based on greps suggested by Amazon at:
+ https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/
+ */
+ validation: val => /^[A-Za-z0-9/+=]{40}$/.test(val),
+ invalidMessage: __('This variable does not match the expected pattern.'),
+ },
+};
+
+export const awsTokenList = Object.keys(awsTokens);
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index 316408adfb2..8f5acd4a0a0 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -1,8 +1,4 @@
<script>
-import { __ } from '~/locale';
-import { mapActions, mapState } from 'vuex';
-import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
-import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
import {
GlDeprecatedButton,
GlModal,
@@ -14,11 +10,19 @@ import {
GlLink,
GlIcon,
} from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+import { __ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { ADD_CI_VARIABLE_MODAL_ID } from '../constants';
+import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens';
+import CiKeyField from './ci_key_field.vue';
+import CiEnvironmentsDropdown from './ci_environments_dropdown.vue';
export default {
modalId: ADD_CI_VARIABLE_MODAL_ID,
components: {
CiEnvironmentsDropdown,
+ CiKeyField,
GlDeprecatedButton,
GlModal,
GlFormSelect,
@@ -29,6 +33,9 @@ export default {
GlLink,
GlIcon,
},
+ mixins: [glFeatureFlagsMixin()],
+ tokens: awsTokens,
+ tokenList: awsTokenList,
computed: {
...mapState([
'projectId',
@@ -41,23 +48,24 @@ export default {
'selectedEnvironment',
]),
canSubmit() {
- if (this.variableData.masked && this.maskedState === false) {
- return false;
- }
- return this.variableData.key !== '' && this.variableData.secret_value !== '';
+ return (
+ this.variableValidationState &&
+ this.variableData.key !== '' &&
+ this.variableData.secret_value !== ''
+ );
},
canMask() {
const regex = RegExp(this.maskableRegex);
return regex.test(this.variableData.secret_value);
},
displayMaskedError() {
- return !this.canMask && this.variableData.masked && this.variableData.secret_value !== '';
+ return !this.canMask && this.variableData.masked;
},
maskedState() {
if (this.displayMaskedError) {
return false;
}
- return null;
+ return true;
},
variableData() {
return this.variableBeingEdited || this.variable;
@@ -66,7 +74,41 @@ export default {
return this.variableBeingEdited ? __('Update variable') : __('Add variable');
},
maskedFeedback() {
- return __('This variable can not be masked');
+ return this.displayMaskedError ? __('This variable can not be masked.') : '';
+ },
+ tokenValidationFeedback() {
+ const tokenSpecificFeedback = this.$options.tokens?.[this.variableData.key]?.invalidMessage;
+ if (!this.tokenValidationState && tokenSpecificFeedback) {
+ return tokenSpecificFeedback;
+ }
+ return '';
+ },
+ tokenValidationState() {
+ // If the feature flag is off, do not validate. Remove when flag is removed.
+ if (!this.glFeatures.ciKeyAutocomplete) {
+ return true;
+ }
+
+ const validator = this.$options.tokens?.[this.variableData.key]?.validation;
+
+ if (validator) {
+ return validator(this.variableData.secret_value);
+ }
+
+ return true;
+ },
+ variableValidationFeedback() {
+ return `${this.tokenValidationFeedback} ${this.maskedFeedback}`;
+ },
+ variableValidationState() {
+ if (
+ this.variableData.secret_value === '' ||
+ (this.tokenValidationState && this.maskedState)
+ ) {
+ return true;
+ }
+
+ return false;
},
},
methods: {
@@ -82,14 +124,13 @@ export default {
'resetSelectedEnvironment',
'setSelectedEnvironment',
]),
- updateOrAddVariable() {
- if (this.variableBeingEdited) {
- this.updateVariable(this.variableBeingEdited);
- } else {
- this.addVariable();
- }
+ deleteVarAndClose() {
+ this.deleteVariable(this.variableBeingEdited);
this.hideModal();
},
+ hideModal() {
+ this.$refs.modal.hide();
+ },
resetModalHandler() {
if (this.variableBeingEdited) {
this.resetEditing();
@@ -98,11 +139,12 @@ export default {
}
this.resetSelectedEnvironment();
},
- hideModal() {
- this.$refs.modal.hide();
- },
- deleteVarAndClose() {
- this.deleteVariable(this.variableBeingEdited);
+ updateOrAddVariable() {
+ if (this.variableBeingEdited) {
+ this.updateVariable(this.variableBeingEdited);
+ } else {
+ this.addVariable();
+ }
this.hideModal();
},
},
@@ -119,7 +161,13 @@ export default {
@hidden="resetModalHandler"
>
<form>
- <gl-form-group :label="__('Key')" label-for="ci-variable-key">
+ <ci-key-field
+ v-if="glFeatures.ciKeyAutocomplete"
+ v-model="variableData.key"
+ :token-list="$options.tokenList"
+ />
+
+ <gl-form-group v-else :label="__('Key')" label-for="ci-variable-key">
<gl-form-input
id="ci-variable-key"
v-model="variableData.key"
@@ -130,12 +178,14 @@ export default {
<gl-form-group
:label="__('Value')"
label-for="ci-variable-value"
- :state="maskedState"
- :invalid-feedback="maskedFeedback"
+ :state="variableValidationState"
+ :invalid-feedback="variableValidationFeedback"
>
<gl-form-textarea
id="ci-variable-value"
+ ref="valueField"
v-model="variableData.secret_value"
+ :state="variableValidationState"
rows="3"
max-rows="6"
data-qa-selector="ci_variable_value_field"
diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js
index d22138db102..5fe1e32e37e 100644
--- a/app/assets/javascripts/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci_variable_list/constants.js
@@ -14,3 +14,8 @@ export const types = {
fileType: 'file',
allEnvironmentsType: '*',
};
+
+// AWS TOKEN CONSTANTS
+export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID';
+export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION';
+export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY';
diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js
index 6bc4be7b93a..6af9b10f12f 100644
--- a/app/assets/javascripts/clusters/services/application_state_machine.js
+++ b/app/assets/javascripts/clusters/services/application_state_machine.js
@@ -191,7 +191,8 @@ const applicationStateMachine = {
* @param {*} event
*/
const transitionApplicationState = (application, event) => {
- const newState = applicationStateMachine[application.status].on[event];
+ const stateMachine = applicationStateMachine[application.status];
+ const newState = stateMachine !== undefined ? stateMachine.on[event] : false;
return newState
? {
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index 51879f280e0..41988f321e5 100644
--- a/app/assets/javascripts/contextual_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
+import { debounce } from 'lodash';
import Cookies from 'js-cookie';
-import _ from 'underscore';
import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
import { parseBoolean } from '~/lib/utils/common_utils';
@@ -43,7 +43,7 @@ export default class ContextualSidebar {
$(document).trigger('content.resize');
});
- $(window).on('resize', () => _.debounce(this.render(), 100));
+ $(window).on('resize', debounce(() => this.render(), 100));
}
// See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index 229612f5e9d..ba585444ba5 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -1,5 +1,5 @@
/* eslint-disable no-new */
-import _ from 'underscore';
+import { debounce } from 'lodash';
import axios from './lib/utils/axios_utils';
import Flash from './flash';
import DropLab from './droplab/drop_lab';
@@ -55,7 +55,7 @@ export default class CreateMergeRequestDropdown {
this.isCreatingMergeRequest = false;
this.isGettingRef = false;
this.mergeRequestCreated = false;
- this.refDebounce = _.debounce((value, target) => this.getRef(value, target), 500);
+ this.refDebounce = debounce((value, target) => this.getRef(value, target), 500);
this.refIsValid = true;
this.refsPath = this.wrapperEl.dataset.refsPath;
this.suggestedRef = this.refInput.value;
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index 6d2b11e39d3..f609ca5f22d 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -59,16 +59,10 @@ export default () => {
service: this.createCycleAnalyticsService(cycleAnalyticsEl.dataset.requestPath),
};
},
- defaultNumberOfSummaryItems: 3,
computed: {
currentStage() {
return this.store.currentActiveStage();
},
- summaryTableColumnClass() {
- return this.state.summary.length === this.$options.defaultNumberOfSummaryItems
- ? 'col-sm-3'
- : 'col-sm-4';
- },
},
created() {
// Conditional check placed here to prevent this method from being called on the
diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue
index 9544fbe9fc5..514d26862a3 100644
--- a/app/assets/javascripts/diffs/components/diff_table_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue
@@ -99,8 +99,12 @@ export default {
return this.showCommentButton && this.hasDiscussions;
},
shouldRenderCommentButton() {
- const isDiffHead = parseBoolean(getParameterByName('diff_head'));
- return !isDiffHead && this.isLoggedIn && this.showCommentButton;
+ if (this.isLoggedIn && this.showCommentButton) {
+ const isDiffHead = parseBoolean(getParameterByName('diff_head'));
+ return !isDiffHead || gon.features?.mergeRefHeadComments;
+ }
+
+ return false;
},
isMatchLine() {
return this.line.type === MATCH_LINE_TYPE;
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index b07dfe5f33d..40e1aec42ed 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -60,3 +60,4 @@ export const PARALLEL_DIFF_LINES_KEY = 'parallel_diff_lines';
export const DIFFS_PER_PAGE = 20;
export const DIFF_COMPARE_BASE_VERSION_INDEX = -1;
+export const DIFF_COMPARE_HEAD_VERSION_INDEX = -2;
diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
index 14c51602f28..dd682060b4b 100644
--- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
+++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
@@ -1,5 +1,6 @@
import { __, n__, sprintf } from '~/locale';
-import { DIFF_COMPARE_BASE_VERSION_INDEX } from '../constants';
+import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils';
+import { DIFF_COMPARE_BASE_VERSION_INDEX, DIFF_COMPARE_HEAD_VERSION_INDEX } from '../constants';
export const selectedTargetIndex = state =>
state.startVersion?.version_index || DIFF_COMPARE_BASE_VERSION_INDEX;
@@ -9,12 +10,25 @@ export const selectedSourceIndex = state => state.mergeRequestDiff.version_index
export const diffCompareDropdownTargetVersions = (state, getters) => {
// startVersion only exists if the user has selected a version other
// than "base" so if startVersion is null then base must be selected
+
+ const diffHead = parseBoolean(getParameterByName('diff_head'));
+ const isBaseSelected = !state.startVersion && !diffHead;
+ const isHeadSelected = !state.startVersion && diffHead;
+
const baseVersion = {
versionName: state.targetBranchName,
version_index: DIFF_COMPARE_BASE_VERSION_INDEX,
href: state.mergeRequestDiff.base_version_path,
isBase: true,
- selected: !state.startVersion,
+ selected: isBaseSelected,
+ };
+
+ const headVersion = {
+ versionName: state.targetBranchName,
+ version_index: DIFF_COMPARE_HEAD_VERSION_INDEX,
+ href: state.mergeRequestDiff.head_version_path,
+ isHead: true,
+ selected: isHeadSelected,
};
// Appended properties here are to make the compare_dropdown_layout easier to reason about
const formatVersion = v => {
@@ -25,7 +39,7 @@ export const diffCompareDropdownTargetVersions = (state, getters) => {
...v,
};
};
- return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion];
+ return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion, headVersion];
};
export const diffCompareDropdownSourceVersions = (state, getters) => {
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index cc9bfa2e174..104686993a8 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -182,15 +182,18 @@ export default {
[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) {
const { latestDiff } = state;
- const discussionLineCode = discussion.line_code;
+ const discussionLineCodes = [discussion.line_code, ...(discussion.line_codes || [])];
const fileHash = discussion.diff_file.file_hash;
const lineCheck = line =>
- line.line_code === discussionLineCode &&
- isDiscussionApplicableToLine({
- discussion,
- diffPosition: diffPositionByLineCode[line.line_code],
- latestDiff,
- });
+ discussionLineCodes.some(
+ discussionLineCode =>
+ line.line_code === discussionLineCode &&
+ isDiscussionApplicableToLine({
+ discussion,
+ diffPosition: diffPositionByLineCode[line.line_code],
+ latestDiff,
+ }),
+ );
const mapDiscussions = (line, extraCheck = () => true) => ({
...line,
discussions: extraCheck()
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 9c788e283b9..dd8dec49a37 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -424,6 +424,7 @@ export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) {
old_path: file.old_path,
old_line: line.old_line,
new_line: line.new_line,
+ line_range: null,
line_code: line.line_code,
position_type: 'text',
};
@@ -439,10 +440,13 @@ export function isDiscussionApplicableToLine({ discussion, diffPosition, latestD
const { line_code, ...diffPositionCopy } = diffPosition;
if (discussion.original_position && discussion.position) {
- const originalRefs = discussion.original_position;
- const refs = discussion.position;
+ const discussionPositions = [
+ discussion.original_position,
+ discussion.position,
+ ...(discussion.positions || []),
+ ];
- return isEqual(refs, diffPositionCopy) || isEqual(originalRefs, diffPositionCopy);
+ return discussionPositions.some(position => isEqual(position, diffPositionCopy));
}
// eslint-disable-next-line
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index be2eee828ff..4aad54bed55 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import _ from 'underscore';
+import { debounce } from 'lodash';
import axios from './lib/utils/axios_utils';
/**
@@ -29,7 +29,7 @@ export default class FilterableList {
initSearch() {
// Wrap to prevent passing event arguments to .filterResults;
- this.debounceFilter = _.debounce(this.onFilterInput.bind(this), 500);
+ this.debounceFilter = debounce(this.onFilterInput.bind(this), 500);
this.unbindEvents();
this.bindEvents();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 7ea7313f648..724f80f8866 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -14,7 +14,13 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
import DropdownUtils from './dropdown_utils';
-import { BACKSPACE_KEY_CODE } from '~/lib/utils/keycodes';
+import {
+ ENTER_KEY_CODE,
+ BACKSPACE_KEY_CODE,
+ DELETE_KEY_CODE,
+ UP_KEY_CODE,
+ DOWN_KEY_CODE,
+} from '~/lib/utils/keycodes';
import { __ } from '~/locale';
export default class FilteredSearchManager {
@@ -176,6 +182,8 @@ export default class FilteredSearchManager {
this.checkForEnterWrapper = this.checkForEnter.bind(this);
this.onClearSearchWrapper = this.onClearSearch.bind(this);
this.checkForBackspaceWrapper = this.checkForBackspace.call(this);
+ this.checkForMetaBackspaceWrapper = this.checkForMetaBackspace.bind(this);
+ this.checkForAltOrCtrlBackspaceWrapper = this.checkForAltOrCtrlBackspace.bind(this);
this.removeSelectedTokenKeydownWrapper = this.removeSelectedTokenKeydown.bind(this);
this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
this.editTokenWrapper = this.editToken.bind(this);
@@ -192,6 +200,9 @@ export default class FilteredSearchManager {
this.filteredSearchInput.addEventListener('keyup', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper);
+ // e.metaKey only works with keydown, not keyup
+ this.filteredSearchInput.addEventListener('keydown', this.checkForMetaBackspaceWrapper);
+ this.filteredSearchInput.addEventListener('keydown', this.checkForAltOrCtrlBackspaceWrapper);
this.filteredSearchInput.addEventListener('click', this.tokenChange);
this.filteredSearchInput.addEventListener('keyup', this.tokenChange);
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
@@ -213,6 +224,8 @@ export default class FilteredSearchManager {
this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper);
this.filteredSearchInput.removeEventListener('keyup', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper);
+ this.filteredSearchInput.removeEventListener('keydown', this.checkForMetaBackspaceWrapper);
+ this.filteredSearchInput.removeEventListener('keydown', this.checkForAltOrCtrlBackspaceWrapper);
this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper);
this.filteredSearchInput.removeEventListener('click', this.tokenChange);
this.filteredSearchInput.removeEventListener('keyup', this.tokenChange);
@@ -235,7 +248,11 @@ export default class FilteredSearchManager {
return e => {
// 8 = Backspace Key
// 46 = Delete Key
- if (e.keyCode === 8 || e.keyCode === 46) {
+ // Handled by respective backspace-combination check functions
+ if (e.altKey || e.ctrlKey || e.metaKey) {
+ return;
+ }
+ if (e.keyCode === BACKSPACE_KEY_CODE || e.keyCode === DELETE_KEY_CODE) {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const { tokenName, tokenValue } = DropdownUtils.getVisualTokenValues(lastVisualToken);
const canEdit = tokenName && this.canEdit && this.canEdit(tokenName, tokenValue);
@@ -258,15 +275,31 @@ export default class FilteredSearchManager {
};
}
+ checkForAltOrCtrlBackspace(e) {
+ if ((e.altKey || e.ctrlKey) && e.keyCode === BACKSPACE_KEY_CODE) {
+ // Default to native OS behavior if input value present
+ if (this.filteredSearchInput.value === '') {
+ FilteredSearchVisualTokens.removeLastTokenPartial();
+ }
+ }
+ }
+
+ checkForMetaBackspace(e) {
+ const onlyMeta = e.metaKey && !e.altKey && !e.ctrlKey && !e.shiftKey;
+ if (onlyMeta && e.keyCode === BACKSPACE_KEY_CODE) {
+ this.clearSearch();
+ }
+ }
+
checkForEnter(e) {
- if (e.keyCode === 38 || e.keyCode === 40) {
+ if (e.keyCode === UP_KEY_CODE || e.keyCode === DOWN_KEY_CODE) {
const { selectionStart } = this.filteredSearchInput;
e.preventDefault();
this.filteredSearchInput.setSelectionRange(selectionStart, selectionStart);
}
- if (e.keyCode === 13) {
+ if (e.keyCode === ENTER_KEY_CODE) {
const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown];
const dropdownEl = dropdown.element;
const activeElements = dropdownEl.querySelectorAll('.droplab-item-active');
@@ -375,7 +408,7 @@ export default class FilteredSearchManager {
removeSelectedTokenKeydown(e) {
// 8 = Backspace Key
// 46 = Delete Key
- if (e.keyCode === 8 || e.keyCode === 46) {
+ if (e.keyCode === BACKSPACE_KEY_CODE || e.keyCode === DELETE_KEY_CODE) {
this.removeSelectedToken();
}
}
diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue
index f0f5b8395c9..c7acc21378b 100644
--- a/app/assets/javascripts/groups/components/groups.vue
+++ b/app/assets/javascripts/groups/components/groups.vue
@@ -32,7 +32,7 @@ export default {
},
methods: {
change(page) {
- const filterGroupsParam = getParameterByName('filter_groups');
+ const filterGroupsParam = getParameterByName('filter');
const sortParam = getParameterByName('sort');
const archivedParam = getParameterByName('archived');
eventHub.$emit(`${this.action}fetchPage`, page, filterGroupsParam, sortParam, archivedParam);
diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue
index 437239ce0be..b71c06e4217 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_app.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue
@@ -1,12 +1,20 @@
<script>
-import getJiraProjects from '../queries/getJiraProjects.query.graphql';
+import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql';
+import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql';
+import { IMPORT_STATE, isInProgress } from '../utils';
import JiraImportForm from './jira_import_form.vue';
+import JiraImportProgress from './jira_import_progress.vue';
import JiraImportSetup from './jira_import_setup.vue';
export default {
name: 'JiraImportApp',
components: {
+ GlAlert,
+ GlLoadingIcon,
JiraImportForm,
+ JiraImportProgress,
JiraImportSetup,
},
props: {
@@ -14,6 +22,18 @@ export default {
type: Boolean,
required: true,
},
+ inProgressIllustration: {
+ type: String,
+ required: true,
+ },
+ issuesPath: {
+ type: String,
+ required: true,
+ },
+ jiraProjects: {
+ type: Array,
+ required: true,
+ },
projectPath: {
type: String,
required: true,
@@ -23,26 +43,111 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ errorMessage: '',
+ showAlert: false,
+ };
+ },
apollo: {
- getJiraImports: {
- query: getJiraProjects,
+ jiraImportDetails: {
+ query: getJiraImportDetailsQuery,
variables() {
return {
fullPath: this.projectPath,
};
},
- update: data => data.project.jiraImports,
+ update: ({ project }) => ({
+ status: project.jiraImportStatus,
+ import: project.jiraImports.nodes[0],
+ }),
skip() {
return !this.isJiraConfigured;
},
},
},
+ computed: {
+ isImportInProgress() {
+ return isInProgress(this.jiraImportDetails?.status);
+ },
+ jiraProjectsOptions() {
+ return this.jiraProjects.map(([text, value]) => ({ text, value }));
+ },
+ },
+ methods: {
+ dismissAlert() {
+ this.showAlert = false;
+ },
+ initiateJiraImport(project) {
+ this.$apollo
+ .mutate({
+ mutation: initiateJiraImportMutation,
+ variables: {
+ input: {
+ projectPath: this.projectPath,
+ jiraProjectKey: project,
+ },
+ },
+ update: (store, { data }) => {
+ if (data.jiraImportStart.errors.length) {
+ return;
+ }
+
+ store.writeQuery({
+ query: getJiraImportDetailsQuery,
+ variables: {
+ fullPath: this.projectPath,
+ },
+ data: {
+ project: {
+ jiraImportStatus: IMPORT_STATE.SCHEDULED,
+ jiraImports: {
+ nodes: [data.jiraImportStart.jiraImport],
+ __typename: 'JiraImportConnection',
+ },
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Project',
+ },
+ },
+ });
+ },
+ })
+ .then(({ data }) => {
+ if (data.jiraImportStart.errors.length) {
+ this.setAlertMessage(data.jiraImportStart.errors.join('. '));
+ }
+ })
+ .catch(() => this.setAlertMessage(__('There was an error importing the Jira project.')));
+ },
+ setAlertMessage(message) {
+ this.errorMessage = message;
+ this.showAlert = true;
+ },
+ },
};
</script>
<template>
<div>
+ <gl-alert v-if="showAlert" variant="danger" @dismiss="dismissAlert">
+ {{ errorMessage }}
+ </gl-alert>
+
<jira-import-setup v-if="!isJiraConfigured" :illustration="setupIllustration" />
- <jira-import-form v-else />
+ <gl-loading-icon v-else-if="$apollo.loading" size="md" class="mt-3" />
+ <jira-import-progress
+ v-else-if="isImportInProgress"
+ :illustration="inProgressIllustration"
+ :import-initiator="jiraImportDetails.import.scheduledBy.name"
+ :import-project="jiraImportDetails.import.jiraProjectKey"
+ :import-time="jiraImportDetails.import.scheduledAt"
+ :issues-path="issuesPath"
+ />
+ <jira-import-form
+ v-else
+ :issues-path="issuesPath"
+ :jira-projects="jiraProjectsOptions"
+ @initiateJiraImport="initiateJiraImport"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue
index 4de04efe1b0..0146f564260 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_form.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue
@@ -1,17 +1,50 @@
<script>
-import { GlAvatar, GlNewButton, GlFormGroup, GlFormSelect, GlLabel } from '@gitlab/ui';
+import { GlAvatar, GlButton, GlFormGroup, GlFormSelect, GlLabel } from '@gitlab/ui';
export default {
name: 'JiraImportForm',
components: {
GlAvatar,
- GlNewButton,
+ GlButton,
GlFormGroup,
GlFormSelect,
GlLabel,
},
currentUserAvatarUrl: gon.current_user_avatar_url,
currentUsername: gon.current_username,
+ props: {
+ issuesPath: {
+ type: String,
+ required: true,
+ },
+ jiraProjects: {
+ type: Array,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ selectedOption: null,
+ selectState: null,
+ };
+ },
+ methods: {
+ initiateJiraImport(event) {
+ event.preventDefault();
+ if (!this.selectedOption) {
+ this.showValidationError();
+ } else {
+ this.hideValidationError();
+ this.$emit('initiateJiraImport', this.selectedOption);
+ }
+ },
+ hideValidationError() {
+ this.selectState = null;
+ },
+ showValidationError() {
+ this.selectState = false;
+ },
+ },
};
</script>
@@ -19,14 +52,21 @@ export default {
<div>
<h3 class="page-title">{{ __('New Jira import') }}</h3>
<hr />
- <form>
+ <form @submit="initiateJiraImport">
<gl-form-group
class="row align-items-center"
+ :invalid-feedback="__('Please select a Jira project')"
:label="__('Import from')"
label-cols-sm="2"
label-for="jira-project-select"
>
- <gl-form-select id="jira-project-select" class="mb-2" />
+ <gl-form-select
+ id="jira-project-select"
+ v-model="selectedOption"
+ class="mb-2"
+ :options="jiraProjects"
+ :state="selectState"
+ />
</gl-form-group>
<gl-form-group
@@ -86,8 +126,10 @@ export default {
</gl-form-group>
<div class="footer-block row-content-block d-flex justify-content-between">
- <gl-new-button category="primary" variant="success">{{ __('Next') }}</gl-new-button>
- <gl-new-button>{{ __('Cancel') }}</gl-new-button>
+ <gl-button type="submit" category="primary" variant="success" class="js-no-auto-disable">
+ {{ __('Next') }}
+ </gl-button>
+ <gl-button :href="issuesPath">{{ __('Cancel') }}</gl-button>
</div>
</form>
</div>
diff --git a/app/assets/javascripts/jira_import/components/jira_import_progress.vue b/app/assets/javascripts/jira_import/components/jira_import_progress.vue
new file mode 100644
index 00000000000..2d610224658
--- /dev/null
+++ b/app/assets/javascripts/jira_import/components/jira_import_progress.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import { formatDate } from '~/lib/utils/datetime_utility';
+import { __, sprintf } from '~/locale';
+
+export default {
+ name: 'JiraImportProgress',
+ components: {
+ GlEmptyState,
+ },
+ props: {
+ illustration: {
+ type: String,
+ required: true,
+ },
+ importInitiator: {
+ type: String,
+ required: true,
+ },
+ importProject: {
+ type: String,
+ required: true,
+ },
+ importTime: {
+ type: String,
+ required: true,
+ },
+ issuesPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ importInitiatorText() {
+ return sprintf(__('Import started by: %{importInitiator}'), {
+ importInitiator: this.importInitiator,
+ });
+ },
+ importProjectText() {
+ return sprintf(__('Jira project: %{importProject}'), {
+ importProject: this.importProject,
+ });
+ },
+ importTimeText() {
+ return sprintf(__('Time of import: %{importTime}'), {
+ importTime: formatDate(this.importTime),
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :svg-path="illustration"
+ :title="__('Import in progress')"
+ :primary-button-text="__('View issues')"
+ :primary-button-link="issuesPath"
+ >
+ <template #description>
+ <p class="mb-0">{{ importInitiatorText }}</p>
+ <p class="mb-0">{{ importTimeText }}</p>
+ <p class="mb-0">{{ importProjectText }}</p>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/jira_import/components/jira_import_setup.vue b/app/assets/javascripts/jira_import/components/jira_import_setup.vue
index 917930397f4..44773a773d5 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_setup.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_setup.vue
@@ -1,6 +1,11 @@
<script>
+import { GlEmptyState } from '@gitlab/ui';
+
export default {
name: 'JiraImportSetup',
+ components: {
+ GlEmptyState,
+ },
props: {
illustration: {
type: String,
@@ -11,15 +16,11 @@ export default {
</script>
<template>
- <div class="empty-state">
- <div class="svg-content">
- <img :src="illustration" :alt="__('Set up Jira Integration illustration')" />
- </div>
- <div class="text-content d-flex flex-column align-items-center">
- <p>{{ __('You will first need to set up Jira Integration to use this feature.') }}</p>
- <a class="btn btn-success" href="../services/jira/edit">
- {{ __('Set up Jira Integration') }}
- </a>
- </div>
- </div>
+ <gl-empty-state
+ :svg-path="illustration"
+ title=""
+ :description="__('You will first need to set up Jira Integration to use this feature.')"
+ :primary-button-text="__('Set up Jira Integration')"
+ primary-button-link="../services/jira/edit"
+ />
</template>
diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js
index 13b16b81c49..8bd70e4e277 100644
--- a/app/assets/javascripts/jira_import/index.js
+++ b/app/assets/javascripts/jira_import/index.js
@@ -24,7 +24,10 @@ export default function mountJiraImportApp() {
render(createComponent) {
return createComponent(App, {
props: {
+ inProgressIllustration: el.dataset.inProgressIllustration,
isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured),
+ issuesPath: el.dataset.issuesPath,
+ jiraProjects: el.dataset.jiraProjects ? JSON.parse(el.dataset.jiraProjects) : [],
projectPath: el.dataset.projectPath,
setupIllustration: el.dataset.setupIllustration,
},
diff --git a/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql b/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql
deleted file mode 100644
index 13100eac221..00000000000
--- a/app/assets/javascripts/jira_import/queries/getJiraProjects.query.graphql
+++ /dev/null
@@ -1,14 +0,0 @@
-query getJiraProjects($fullPath: ID!) {
- project(fullPath: $fullPath) {
- jiraImportStatus
- jiraImports {
- nodes {
- jiraProjectKey
- scheduledAt
- scheduledBy {
- username
- }
- }
- }
- }
-}
diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
new file mode 100644
index 00000000000..0eaaad580fc
--- /dev/null
+++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
@@ -0,0 +1,12 @@
+#import "./jira_import.fragment.graphql"
+
+query($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ jiraImportStatus
+ jiraImports(last: 1) {
+ nodes {
+ ...JiraImport
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql
new file mode 100644
index 00000000000..8fda8287988
--- /dev/null
+++ b/app/assets/javascripts/jira_import/queries/initiate_jira_import.mutation.graphql
@@ -0,0 +1,11 @@
+#import "./jira_import.fragment.graphql"
+
+mutation($input: JiraImportStartInput!) {
+ jiraImportStart(input: $input) {
+ clientMutationId
+ jiraImport {
+ ...JiraImport
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql
new file mode 100644
index 00000000000..fde2ebeff91
--- /dev/null
+++ b/app/assets/javascripts/jira_import/queries/jira_import.fragment.graphql
@@ -0,0 +1,7 @@
+fragment JiraImport on JiraImport {
+ jiraProjectKey
+ scheduledAt
+ scheduledBy {
+ name
+ }
+}
diff --git a/app/assets/javascripts/jira_import/utils.js b/app/assets/javascripts/jira_import/utils.js
new file mode 100644
index 00000000000..504cf19e44e
--- /dev/null
+++ b/app/assets/javascripts/jira_import/utils.js
@@ -0,0 +1,10 @@
+export const IMPORT_STATE = {
+ FAILED: 'failed',
+ FINISHED: 'finished',
+ NONE: 'none',
+ SCHEDULED: 'scheduled',
+ STARTED: 'started',
+};
+
+export const isInProgress = state =>
+ state === IMPORT_STATE.SCHEDULED || state === IMPORT_STATE.STARTED;
diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js
index 9e8edd05b88..a464290ffb5 100644
--- a/app/assets/javascripts/lazy_loader.js
+++ b/app/assets/javascripts/lazy_loader.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { debounce, throttle } from 'lodash';
export const placeholderImage =
'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
@@ -82,7 +82,7 @@ export default class LazyLoader {
}
startIntersectionObserver = () => {
- this.throttledElementsInView = _.throttle(() => this.checkElementsInView(), 300);
+ this.throttledElementsInView = throttle(() => this.checkElementsInView(), 300);
this.intersectionObserver = new IntersectionObserver(this.onIntersection, {
rootMargin: `${SCROLL_THRESHOLD}px 0px`,
thresholds: 0.1,
@@ -102,8 +102,8 @@ export default class LazyLoader {
};
startLegacyObserver() {
- this.throttledScrollCheck = _.throttle(() => this.scrollCheck(), 300);
- this.debouncedElementsInView = _.debounce(() => this.checkElementsInView(), 300);
+ this.throttledScrollCheck = throttle(() => this.scrollCheck(), 300);
+ this.debouncedElementsInView = debounce(() => this.checkElementsInView(), 300);
window.addEventListener('scroll', this.throttledScrollCheck);
window.addEventListener('resize', this.debouncedElementsInView);
}
diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js
index 2270d329c24..16bffc5c2cf 100644
--- a/app/assets/javascripts/lib/utils/keycodes.js
+++ b/app/assets/javascripts/lib/utils/keycodes.js
@@ -1,5 +1,6 @@
-export const UP_KEY_CODE = 38;
-export const DOWN_KEY_CODE = 40;
+export const BACKSPACE_KEY_CODE = 8;
export const ENTER_KEY_CODE = 13;
export const ESC_KEY_CODE = 27;
-export const BACKSPACE_KEY_CODE = 8;
+export const UP_KEY_CODE = 38;
+export const DOWN_KEY_CODE = 40;
+export const DELETE_KEY_CODE = 46;
diff --git a/app/assets/javascripts/lib/utils/unit_format/index.js b/app/assets/javascripts/lib/utils/unit_format/index.js
index d3aea37e677..adf374db66c 100644
--- a/app/assets/javascripts/lib/utils/unit_format/index.js
+++ b/app/assets/javascripts/lib/utils/unit_format/index.js
@@ -1,3 +1,4 @@
+import { engineeringNotation } from '@gitlab/ui/src/utils/number_utils';
import { s__ } from '~/locale';
import {
@@ -39,15 +40,18 @@ export const SUPPORTED_FORMATS = {
gibibytes: 'gibibytes',
tebibytes: 'tebibytes',
pebibytes: 'pebibytes',
+
+ // Engineering Notation
+ engineering: 'engineering',
};
/**
* Returns a function that formats number to different units
- * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to number.
+ * @param {String} format - Format to use, must be one of the SUPPORTED_FORMATS. Defaults to engineering notation.
*
*
*/
-export const getFormatter = (format = SUPPORTED_FORMATS.number) => {
+export const getFormatter = (format = SUPPORTED_FORMATS.engineering) => {
// Number
if (format === SUPPORTED_FORMATS.number) {
@@ -252,6 +256,17 @@ export const getFormatter = (format = SUPPORTED_FORMATS.number) => {
return scaledBinaryFormatter('B', 5);
}
+ if (format === SUPPORTED_FORMATS.engineering) {
+ /**
+ * Formats via engineering notation
+ *
+ * @function
+ * @param {Number} value - Value to format
+ * @param {Number} fractionDigits - precision decimals - Defaults to 2
+ */
+ return engineeringNotation;
+ }
+
// Fail so client library addresses issue
throw TypeError(`${format} is not a valid number format`);
};
diff --git a/app/assets/javascripts/logs/constants.js b/app/assets/javascripts/logs/constants.js
index 450b83f4827..51770aa7a1c 100644
--- a/app/assets/javascripts/logs/constants.js
+++ b/app/assets/javascripts/logs/constants.js
@@ -1,3 +1,3 @@
-export const dateFormatMask = 'UTC:mmm dd HH:MM:ss.l"Z"';
+export const dateFormatMask = 'mmm dd HH:MM:ss.l';
export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME';
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 81b2e9f13a5..6c8f6372795 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -298,6 +298,18 @@ document.addEventListener('DOMContentLoaded', () => {
if ($gutterIcon.hasClass('fa-angle-double-right')) {
$sidebarGutterToggle.trigger('click');
}
+
+ const sidebarGutterVueToggleEl = document.querySelector('.js-sidebar-vue-toggle');
+
+ // Sidebar has an icon which corresponds to collapsing the sidebar
+ // only then trigger the click.
+ if (sidebarGutterVueToggleEl) {
+ const collapseIcon = sidebarGutterVueToggleEl.querySelector('i.fa-angle-double-right');
+
+ if (collapseIcon) {
+ collapseIcon.click();
+ }
+ }
}
});
diff --git a/app/assets/javascripts/monitoring/components/charts/annotations.js b/app/assets/javascripts/monitoring/components/charts/annotations.js
index 947750b3721..418107c4126 100644
--- a/app/assets/javascripts/monitoring/components/charts/annotations.js
+++ b/app/assets/javascripts/monitoring/components/charts/annotations.js
@@ -1,20 +1,20 @@
-import { graphTypes, symbolSizes, colorValues } from '../../constants';
+import { graphTypes, symbolSizes, colorValues, annotationsSymbolIcon } from '../../constants';
/**
* Annotations and deployments are decoration layers on
* top of the actual chart data. We use a scatter plot to
* display this information. Each chart has its coordinate
- * system based on data and irresptive of the data, these
+ * system based on data and irrespective of the data, these
* decorations have to be placed in specific locations.
* For this reason, annotations have their own coordinate system,
*
* As of %12.9, only deployment icons, a type of annotations, need
* to be displayed on the chart.
*
- * After https://gitlab.com/gitlab-org/gitlab/-/issues/211418,
- * annotations and deployments will co-exist in the same
- * series as they logically belong together. Annotations will be
- * passed as markLine objects.
+ * Annotations and deployments co-exist in the same series as
+ * they logically belong together. Annotations are passed as
+ * markLines and markPoints while deployments are passed as
+ * data points with custom icons.
*/
/**
@@ -45,42 +45,49 @@ export const annotationsYAxis = {
* Fetched list of annotations are parsed into a
* format the eCharts accepts to draw markLines
*
- * If Annotation is a single line, the `starting_at` property
- * has a value and the `ending_at` is null. Because annotations
- * only supports lines the `ending_at` value does not exist yet.
- *
+ * If Annotation is a single line, the `startingAt` property
+ * has a value and the `endingAt` is null. Because annotations
+ * only supports lines the `endingAt` value does not exist yet.
*
* @param {Object} annotation object
* @returns {Object} markLine object
*/
-export const parseAnnotations = ({ starting_at = '', color = colorValues.primaryColor }) => ({
- xAxis: starting_at,
- lineStyle: {
- color,
- },
-});
+export const parseAnnotations = annotations =>
+ annotations.reduce(
+ (acc, annotation) => {
+ acc.lines.push({
+ xAxis: annotation.startingAt,
+ lineStyle: {
+ color: colorValues.primaryColor,
+ },
+ });
+
+ acc.points.push({
+ name: 'annotations',
+ xAxis: annotation.startingAt,
+ yAxis: annotationsYAxisCoords.min,
+ tooltipData: {
+ title: annotation.startingAt,
+ content: annotation.description,
+ },
+ });
+
+ return acc;
+ },
+ { lines: [], points: [] },
+ );
/**
- * This method currently generates deployments and annotations
- * but are not used in the chart. The method calling
- * generateAnnotationsSeries will not pass annotations until
- * https://gitlab.com/gitlab-org/gitlab/-/issues/211330 is
- * implemented.
- *
- * This method is extracted out of the charts so that
- * annotation lines can be easily supported in
- * the future.
- *
- * In order to make hover work, hidden annotation data points
- * are created along with the markLines. These data points have
- * the necessart metadata that is used to display in the tooltip.
+ * This method generates a decorative series that has
+ * deployments as data points with custom icons and
+ * annotations as markLines and markPoints
*
* @param {Array} deployments deployments data
* @returns {Object} annotation series object
*/
export const generateAnnotationsSeries = ({ deployments = [], annotations = [] } = {}) => {
// deployment data points
- const deploymentsData = deployments.map(deployment => {
+ const data = deployments.map(deployment => {
return {
name: 'deployments',
value: [deployment.createdAt, annotationsYAxisCoords.pos],
@@ -98,31 +105,29 @@ export const generateAnnotationsSeries = ({ deployments = [], annotations = [] }
};
});
- // annotation data points
- const annotationsData = annotations.map(annotation => {
- return {
- name: 'annotations',
- value: [annotation.starting_at, annotationsYAxisCoords.pos],
- // style options
- symbol: 'none',
- // metadata that are accessible in `formatTooltipText` method
- tooltipData: {
- description: annotation.description,
- },
- };
- });
+ const parsedAnnotations = parseAnnotations(annotations);
- // annotation markLine option
+ // markLine option draws the annotations dotted line
const markLine = {
symbol: 'none',
silent: true,
- data: annotations.map(parseAnnotations),
+ data: parsedAnnotations.lines,
+ };
+
+ // markPoints are the arrows under the annotations lines
+ const markPoint = {
+ symbol: annotationsSymbolIcon,
+ symbolSize: '8',
+ symbolOffset: [0, ' 60%'],
+ data: parsedAnnotations.points,
};
return {
+ name: 'annotations',
type: graphTypes.annotationsData,
yAxisIndex: 1, // annotationsYAxis index
- data: [...deploymentsData, ...annotationsData],
+ data,
markLine,
+ markPoint,
};
};
diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
index 5588d9ac060..e015ef32d8c 100644
--- a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
+++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
@@ -3,12 +3,6 @@ import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-e
import { chartHeight } from '../../constants';
export default {
- props: {
- graphTitle: {
- type: String,
- required: true,
- },
- },
data() {
return {
height: chartHeight,
diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js
index d9f49bd81f5..09b03774580 100644
--- a/app/assets/javascripts/monitoring/components/charts/options.js
+++ b/app/assets/javascripts/monitoring/components/charts/options.js
@@ -6,9 +6,8 @@ const yAxisBoundaryGap = [0.1, 0.1];
* Max string length of formatted axis tick
*/
const maxDataAxisTickLength = 8;
-
// Defaults
-const defaultFormat = SUPPORTED_FORMATS.number;
+const defaultFormat = SUPPORTED_FORMATS.engineering;
const defaultYAxisFormat = defaultFormat;
const defaultYAxisPrecision = 2;
@@ -26,8 +25,7 @@ const chartGridLeft = 75;
* @param {Object} param - Dashboard .yml definition options
*/
const getDataAxisOptions = ({ format, precision, name }) => {
- const formatter = getFormatter(format);
-
+ const formatter = getFormatter(format); // default to engineeringNotation, same as gitlab-ui
return {
name,
nameLocation: 'center', // same as gitlab-ui's default
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index 9041b01088c..bf40e8f448e 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -6,7 +6,7 @@ import dateFormat from 'dateformat';
import { s__, __ } from '~/locale';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
-import { chartHeight, lineTypes, lineWidths, dateFormats, tooltipTypes } from '../../constants';
+import { chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants';
import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options';
import { annotationsYAxis, generateAnnotationsSeries } from './annotations';
import { makeDataSeries } from '~/helpers/monitor_helper';
@@ -20,7 +20,6 @@ const events = {
};
export default {
- tooltipTypes,
components: {
GlAreaChart,
GlLineChart,
@@ -262,6 +261,21 @@ export default {
isTooltipOfType(tooltipType, defaultType) {
return tooltipType === defaultType;
},
+ /**
+ * This method is triggered when hovered over a single markPoint.
+ *
+ * The annotations title timestamp should match the data tooltip
+ * title.
+ *
+ * @params {Object} params markPoint object
+ * @returns {Object}
+ */
+ formatAnnotationsTooltipText(params) {
+ return {
+ title: dateFormat(params.data?.tooltipData?.title, dateFormats.default),
+ content: params.data?.tooltipData?.content,
+ };
+ },
formatTooltipText(params) {
this.tooltip.title = dateFormat(params.value, dateFormats.default);
this.tooltip.content = [];
@@ -270,15 +284,10 @@ export default {
if (dataPoint.value) {
const [, yVal] = dataPoint.value;
this.tooltip.type = dataPoint.name;
- if (this.isTooltipOfType(this.tooltip.type, this.$options.tooltipTypes.deployments)) {
+ if (this.tooltip.type === 'deployments') {
const { data = {} } = dataPoint;
this.tooltip.sha = data?.tooltipData?.sha;
this.tooltip.commitUrl = data?.tooltipData?.commitUrl;
- } else if (
- this.isTooltipOfType(this.tooltip.type, this.$options.tooltipTypes.annotations)
- ) {
- const { data } = dataPoint;
- this.tooltip.content.push(data?.tooltipData?.description);
} else {
const { seriesName, color, dataIndex } = dataPoint;
@@ -356,6 +365,7 @@ export default {
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
+ :format-annotations-tooltip-text="formatAnnotationsTooltipText"
:thresholds="thresholds"
:width="width"
:height="height"
@@ -364,7 +374,7 @@ export default {
@created="onChartCreated"
@updated="onChartUpdated"
>
- <template v-if="isTooltipOfType(tooltip.type, this.$options.tooltipTypes.deployments)">
+ <template v-if="tooltip.type === 'deployments'">
<template slot="tooltipTitle">
{{ __('Deployed') }}
</template>
@@ -373,16 +383,6 @@ export default {
<gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
</div>
</template>
- <template v-else-if="isTooltipOfType(tooltip.type, this.$options.tooltipTypes.annotations)">
- <template slot="tooltipTitle">
- <div class="text-nowrap">
- {{ tooltip.title }}
- </div>
- </template>
- <div slot="tooltipContent" class="d-flex align-items-center">
- {{ tooltip.content.join('\n') }}
- </div>
- </template>
<template v-else>
<template slot="tooltipTitle">
<div class="text-nowrap">
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 4586ce70ad6..4d60b02d0df 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -8,7 +8,6 @@ import {
GlDropdownItem,
GlDropdownHeader,
GlDropdownDivider,
- GlFormGroup,
GlModal,
GlLoadingIcon,
GlSearchBoxByType,
@@ -19,6 +18,7 @@ import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import { s__ } from '~/locale';
import createFlash from '~/flash';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue';
import { mergeUrlParams, redirectTo, updateHistory } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
import Icon from '~/vue_shared/components/icon.vue';
@@ -46,8 +46,8 @@ export default {
GlDropdownHeader,
GlDropdownDivider,
GlSearchBoxByType,
- GlFormGroup,
GlModal,
+ CustomMetricsFormFields,
DateTimePicker,
GraphGroup,
@@ -206,9 +206,6 @@ export default {
};
},
computed: {
- canAddMetrics() {
- return this.customMetricsAvailable && this.customMetricsPath.length;
- },
...mapState('monitoringDashboard', [
'dashboard',
'emptyState',
@@ -229,7 +226,11 @@ export default {
return !this.showEmptyState && this.rearrangePanelsAvailable;
},
addingMetricsAvailable() {
- return IS_EE && this.canAddMetrics && !this.showEmptyState;
+ return (
+ this.customMetricsAvailable &&
+ !this.showEmptyState &&
+ this.firstDashboard === this.selectedDashboard
+ );
},
hasHeaderButtons() {
return (
@@ -378,177 +379,164 @@ export default {
<div
v-if="showHeader"
ref="prometheusGraphsHeader"
- class="prometheus-graphs-header gl-p-3 pb-0 border-bottom bg-gray-light"
+ class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light"
>
- <div class="row">
- <gl-form-group
- :label="__('Dashboard')"
- label-size="sm"
- label-for="monitor-dashboards-dropdown"
- class="col-sm-12 col-md-6 col-lg-2"
- >
- <dashboards-dropdown
- id="monitor-dashboards-dropdown"
- data-qa-selector="dashboards_filter_dropdown"
- class="mb-0 d-flex"
- toggle-class="dropdown-menu-toggle"
- :default-branch="defaultBranch"
- :selected-dashboard="selectedDashboard"
- @selectDashboard="selectDashboard($event)"
- />
- </gl-form-group>
+ <div class="mb-2 pr-2 d-flex d-sm-block">
+ <dashboards-dropdown
+ id="monitor-dashboards-dropdown"
+ data-qa-selector="dashboards_filter_dropdown"
+ class="flex-grow-1"
+ toggle-class="dropdown-menu-toggle"
+ :default-branch="defaultBranch"
+ :selected-dashboard="selectedDashboard"
+ @selectDashboard="selectDashboard($event)"
+ />
+ </div>
- <gl-form-group
- :label="s__('Metrics|Environment')"
- label-size="sm"
- label-for="monitor-environments-dropdown"
- class="col-sm-6 col-md-6 col-lg-2"
+ <div class="mb-2 pr-2 d-flex d-sm-block">
+ <gl-dropdown
+ id="monitor-environments-dropdown"
+ ref="monitorEnvironmentsDropdown"
+ class="flex-grow-1"
+ data-qa-selector="environments_dropdown"
+ toggle-class="dropdown-menu-toggle"
+ menu-class="monitor-environment-dropdown-menu"
+ :text="currentEnvironmentName"
>
- <gl-dropdown
- id="monitor-environments-dropdown"
- ref="monitorEnvironmentsDropdown"
- data-qa-selector="environments_dropdown"
- class="mb-0 d-flex"
- toggle-class="dropdown-menu-toggle"
- menu-class="monitor-environment-dropdown-menu"
- :text="currentEnvironmentName"
- >
- <div class="d-flex flex-column overflow-hidden">
- <gl-dropdown-header class="monitor-environment-dropdown-header text-center">{{
- __('Environment')
- }}</gl-dropdown-header>
- <gl-dropdown-divider />
- <gl-search-box-by-type
- ref="monitorEnvironmentsDropdownSearch"
- class="m-2"
- @input="debouncedEnvironmentsSearch"
- />
- <gl-loading-icon
- v-if="environmentsLoading"
- ref="monitorEnvironmentsDropdownLoading"
- :inline="true"
- />
- <div v-else class="flex-fill overflow-auto">
- <gl-dropdown-item
- v-for="environment in filteredEnvironments"
- :key="environment.id"
- :active="environment.name === currentEnvironmentName"
- active-class="is-active"
- :href="environment.metrics_path"
- >{{ environment.name }}</gl-dropdown-item
- >
- </div>
- <div
- v-show="shouldShowEnvironmentsDropdownNoMatchedMsg"
- ref="monitorEnvironmentsDropdownMsg"
- class="text-secondary no-matches-message"
+ <div class="d-flex flex-column overflow-hidden">
+ <gl-dropdown-header class="monitor-environment-dropdown-header text-center">
+ {{ __('Environment') }}
+ </gl-dropdown-header>
+ <gl-dropdown-divider />
+ <gl-search-box-by-type
+ ref="monitorEnvironmentsDropdownSearch"
+ class="m-2"
+ @input="debouncedEnvironmentsSearch"
+ />
+ <gl-loading-icon
+ v-if="environmentsLoading"
+ ref="monitorEnvironmentsDropdownLoading"
+ :inline="true"
+ />
+ <div v-else class="flex-fill overflow-auto">
+ <gl-dropdown-item
+ v-for="environment in filteredEnvironments"
+ :key="environment.id"
+ :active="environment.name === currentEnvironmentName"
+ active-class="is-active"
+ :href="environment.metrics_path"
+ >{{ environment.name }}</gl-dropdown-item
>
- {{ __('No matching results') }}
- </div>
</div>
- </gl-dropdown>
- </gl-form-group>
+ <div
+ v-show="shouldShowEnvironmentsDropdownNoMatchedMsg"
+ ref="monitorEnvironmentsDropdownMsg"
+ class="text-secondary no-matches-message"
+ >
+ {{ __('No matching results') }}
+ </div>
+ </div>
+ </gl-dropdown>
+ </div>
- <gl-form-group
- :label="s__('Metrics|Show last')"
- label-size="sm"
- label-for="monitor-time-window-dropdown"
- class="col-sm-auto col-md-auto col-lg-auto"
+ <div class="mb-2 pr-2 d-flex d-sm-block">
+ <date-time-picker
+ ref="dateTimePicker"
+ class="flex-grow-1 show-last-dropdown"
data-qa-selector="show_last_dropdown"
+ :value="selectedTimeRange"
+ :options="timeRanges"
+ @input="onDateTimePickerInput"
+ @invalid="onDateTimePickerInvalid"
+ />
+ </div>
+
+ <div class="mb-2 pr-2 d-flex d-sm-block">
+ <gl-deprecated-button
+ ref="refreshDashboardBtn"
+ v-gl-tooltip
+ class="flex-grow-1"
+ variant="default"
+ :title="s__('Metrics|Refresh dashboard')"
+ @click="refreshDashboard"
>
- <date-time-picker
- ref="dateTimePicker"
- :value="selectedTimeRange"
- :options="timeRanges"
- @input="onDateTimePickerInput"
- @invalid="onDateTimePickerInvalid"
- />
- </gl-form-group>
+ <icon name="retry" />
+ </gl-deprecated-button>
+ </div>
+
+ <div class="flex-grow-1"></div>
- <gl-form-group class="col-sm-2 col-md-2 col-lg-1 refresh-dashboard-button">
+ <div class="d-sm-flex">
+ <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex">
<gl-deprecated-button
- ref="refreshDashboardBtn"
- v-gl-tooltip
+ :pressed="isRearrangingPanels"
variant="default"
- :title="s__('Metrics|Refresh dashboard')"
- @click="refreshDashboard"
+ class="flex-grow-1 js-rearrange-button"
+ @click="toggleRearrangingPanels"
>
- <icon name="retry" />
+ {{ __('Arrange charts') }}
</gl-deprecated-button>
- </gl-form-group>
-
- <gl-form-group
- v-if="hasHeaderButtons"
- label-for="prometheus-graphs-dropdown-buttons"
- class="dropdown-buttons col-md d-md-flex col-lg d-lg-flex align-items-end"
- >
- <div id="prometheus-graphs-dropdown-buttons">
- <gl-deprecated-button
- v-if="showRearrangePanelsBtn"
- :pressed="isRearrangingPanels"
- variant="default"
- class="mr-2 mt-1 js-rearrange-button"
- @click="toggleRearrangingPanels"
- >{{ __('Arrange charts') }}</gl-deprecated-button
- >
- <gl-deprecated-button
- v-if="addingMetricsAvailable"
- ref="addMetricBtn"
- v-gl-modal="$options.addMetric.modalId"
- variant="outline-success"
- data-qa-selector="add_metric_button"
- class="mr-2 mt-1"
- >{{ $options.addMetric.title }}</gl-deprecated-button
- >
- <gl-modal
- v-if="addingMetricsAvailable"
- ref="addMetricModal"
- :modal-id="$options.addMetric.modalId"
- :title="$options.addMetric.title"
- >
- <form ref="customMetricsForm" :action="customMetricsPath" method="post">
- <custom-metrics-form-fields
- :validate-query-path="validateQueryPath"
- form-operation="post"
- @formValidation="setFormValidity"
- />
- </form>
- <div slot="modal-footer">
- <gl-deprecated-button @click="hideAddMetricModal">{{
- __('Cancel')
- }}</gl-deprecated-button>
- <gl-deprecated-button
- ref="submitCustomMetricsFormBtn"
- v-track-event="getAddMetricTrackingOptions()"
- :disabled="!formIsValid"
- variant="success"
- @click="submitCustomMetricsForm"
- >{{ __('Save changes') }}</gl-deprecated-button
- >
- </div>
- </gl-modal>
+ </div>
+ <div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block">
+ <gl-deprecated-button
+ ref="addMetricBtn"
+ v-gl-modal="$options.addMetric.modalId"
+ variant="outline-success"
+ data-qa-selector="add_metric_button"
+ class="flex-grow-1"
+ >
+ {{ $options.addMetric.title }}
+ </gl-deprecated-button>
+ <gl-modal
+ ref="addMetricModal"
+ :modal-id="$options.addMetric.modalId"
+ :title="$options.addMetric.title"
+ >
+ <form ref="customMetricsForm" :action="customMetricsPath" method="post">
+ <custom-metrics-form-fields
+ :validate-query-path="validateQueryPath"
+ form-operation="post"
+ @formValidation="setFormValidity"
+ />
+ </form>
+ <div slot="modal-footer">
+ <gl-deprecated-button @click="hideAddMetricModal">
+ {{ __('Cancel') }}
+ </gl-deprecated-button>
+ <gl-deprecated-button
+ ref="submitCustomMetricsFormBtn"
+ v-track-event="getAddMetricTrackingOptions()"
+ :disabled="!formIsValid"
+ variant="success"
+ @click="submitCustomMetricsForm"
+ >
+ {{ __('Save changes') }}
+ </gl-deprecated-button>
+ </div>
+ </gl-modal>
+ </div>
- <gl-deprecated-button
- v-if="selectedDashboard.can_edit"
- class="mt-1 js-edit-link"
- :href="selectedDashboard.project_blob_path"
- data-qa-selector="edit_dashboard_button"
- >{{ __('Edit dashboard') }}</gl-deprecated-button
- >
+ <div v-if="selectedDashboard.can_edit" class="mb-2 mr-2 d-flex d-sm-block">
+ <gl-deprecated-button
+ class="flex-grow-1 js-edit-link"
+ :href="selectedDashboard.project_blob_path"
+ data-qa-selector="edit_dashboard_button"
+ >
+ {{ __('Edit dashboard') }}
+ </gl-deprecated-button>
+ </div>
- <gl-deprecated-button
- v-if="externalDashboardUrl.length"
- class="mt-1 js-external-dashboard-link"
- variant="primary"
- :href="externalDashboardUrl"
- target="_blank"
- rel="noopener noreferrer"
- >
- {{ __('View full dashboard') }}
- <icon name="external-link" />
- </gl-deprecated-button>
- </div>
- </gl-form-group>
+ <div v-if="externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block">
+ <gl-deprecated-button
+ class="flex-grow-1 js-external-dashboard-link"
+ variant="primary"
+ :href="externalDashboardUrl"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {{ __('View full dashboard') }} <icon name="external-link" />
+ </gl-deprecated-button>
+ </div>
</div>
</div>
diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue
index 676fc0cca64..2beae0d9540 100644
--- a/app/assets/javascripts/monitoring/components/panel_type.vue
+++ b/app/assets/javascripts/monitoring/components/panel_type.vue
@@ -4,6 +4,7 @@ import { pickBy } from 'lodash';
import invalidUrl from '~/lib/utils/invalid_url';
import {
GlResizeObserverDirective,
+ GlIcon,
GlLoadingIcon,
GlDropdown,
GlDropdownItem,
@@ -13,7 +14,6 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { __, n__ } from '~/locale';
-import Icon from '~/vue_shared/components/icon.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorAnomalyChart from './charts/anomaly.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
@@ -37,7 +37,7 @@ export default {
MonitorHeatmapChart,
MonitorStackedColumnChart,
MonitorEmptyChart,
- Icon,
+ GlIcon,
GlLoadingIcon,
GlTooltip,
GlDropdown,
@@ -227,7 +227,7 @@ export default {
</div>
<div
v-if="isContextualMenuShown"
- class="js-graph-widgets"
+ ref="contextualMenu"
data-qa-selector="prometheus_graph_widgets"
>
<div class="d-flex align-items-center">
@@ -240,7 +240,7 @@ export default {
:title="__('More actions')"
>
<template slot="button-content">
- <icon name="ellipsis_v" class="text-secondary" />
+ <gl-icon name="ellipsis_v" class="text-secondary" />
</template>
<gl-dropdown-item
v-if="editCustomMetricLink"
@@ -319,6 +319,6 @@ export default {
:group-id="groupId"
@datazoom="onDatazoom"
/>
- <monitor-empty-chart v-else :graph-title="title" v-bind="$attrs" v-on="$listeners" />
+ <monitor-empty-chart v-else v-bind="$attrs" v-on="$listeners" />
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 8d821c27099..0b393f19789 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -120,10 +120,26 @@ export const NOT_IN_DB_PREFIX = 'NO_DB';
export const ENVIRONMENT_AVAILABLE_STATE = 'available';
/**
- * Time series charts have different types of
- * tooltip based on the hovered data point.
+ * As of %12.10, the svg icon library does not have an annotation
+ * arrow icon yet. In order to deliver annotations feature, the icon
+ * is hard coded until the icon is added. The below issue is
+ * to track the icon.
+ *
+ * https://gitlab.com/gitlab-org/gitlab-svgs/-/issues/118
+ *
+ * Once the icon is merged this can be removed.
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/214540
*/
-export const tooltipTypes = {
- deployments: 'deployments',
- annotations: 'annotations',
-};
+export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z';
+
+/**
+ * As of %12.10, dashboard path is required to create annotation.
+ * The FE gets the dashboard name from the URL params. It is not
+ * ideal to store the path this way but there is no other way to
+ * get this path unless annotations fetch is delayed. This could
+ * potentially be removed and have the backend send this to the FE.
+ *
+ * This technical debt is being tracked here
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/214671
+ */
+export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml';
diff --git a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql
index 2fd698eadf9..27b49860b8a 100644
--- a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql
+++ b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql
@@ -1,12 +1,25 @@
-query getAnnotations($projectPath: ID!) {
- environment(name: $environmentName) {
- metricDashboard(id: $dashboardId) {
- annotations: nodes {
+query getAnnotations(
+ $projectPath: ID!
+ $environmentName: String
+ $dashboardPath: String!
+ $startingFrom: Time!
+) {
+ project(fullPath: $projectPath) {
+ environments(name: $environmentName) {
+ nodes {
id
- description
- starting_at
- ending_at
- panelId
+ name
+ metricsDashboard(path: $dashboardPath) {
+ annotations(from: $startingFrom) {
+ nodes {
+ id
+ description
+ startingAt
+ endingAt
+ panelId
+ }
+ }
+ }
}
}
}
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 5b2bd1f1493..f04f775761c 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -3,7 +3,12 @@ import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
-import { gqClient, parseEnvironmentsResponse, removeLeadingSlash } from './utils';
+import {
+ gqClient,
+ parseEnvironmentsResponse,
+ parseAnnotationsResponse,
+ removeLeadingSlash,
+} from './utils';
import trackDashboardLoad from '../monitoring_tracking_helper';
import getEnvironments from '../queries/getEnvironments.query.graphql';
import getAnnotations from '../queries/getAnnotations.query.graphql';
@@ -15,7 +20,11 @@ import {
} from '../../lib/utils/common_utils';
import { s__, sprintf } from '../../locale';
-import { PROMETHEUS_TIMEOUT, ENVIRONMENT_AVAILABLE_STATE } from '../constants';
+import {
+ PROMETHEUS_TIMEOUT,
+ ENVIRONMENT_AVAILABLE_STATE,
+ DEFAULT_DASHBOARD_PATH,
+} from '../constants';
function prometheusMetricQueryParams(timeRange) {
const { start, end } = convertToFixedRange(timeRange);
@@ -90,7 +99,7 @@ export const fetchData = ({ dispatch }) => {
* ready after the BE piece is implemented.
* https://gitlab.com/gitlab-org/gitlab/-/issues/211330
*/
- if (isFeatureFlagEnabled('metrics_dashboard_annotations')) {
+ if (isFeatureFlagEnabled('metricsDashboardAnnotations')) {
dispatch('fetchAnnotations');
}
};
@@ -283,18 +292,21 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => {
};
export const fetchAnnotations = ({ state, dispatch }) => {
- dispatch('requestAnnotations');
-
+ const { start } = convertToFixedRange(state.timeRange);
+ const dashboardPath =
+ state.currentDashboard === '' ? DEFAULT_DASHBOARD_PATH : state.currentDashboard;
return gqClient
.mutate({
mutation: getAnnotations,
variables: {
projectPath: removeLeadingSlash(state.projectPath),
- dashboardId: state.currentDashboard,
environmentName: state.currentEnvironmentName,
+ dashboardPath,
+ startingFrom: start,
},
})
- .then(resp => resp.data?.project?.environment?.metricDashboard?.annotations)
+ .then(resp => resp.data?.project?.environments?.nodes?.[0].metricsDashboard?.annotations.nodes)
+ .then(parseAnnotationsResponse)
.then(annotations => {
if (!annotations) {
createFlash(s__('Metrics|There was an error fetching annotations. Please try again.'));
@@ -309,9 +321,6 @@ export const fetchAnnotations = ({ state, dispatch }) => {
});
};
-// While this commit does not update the state it will
-// eventually be useful to show a loading state
-export const requestAnnotations = ({ commit }) => commit(types.REQUEST_ANNOTATIONS);
export const receiveAnnotationsSuccess = ({ commit }, data) =>
commit(types.RECEIVE_ANNOTATIONS_SUCCESS, data);
export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_ANNOTATIONS_FAILURE);
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index 2f9955da1b1..27a9a67edaa 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -4,7 +4,6 @@ export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCC
export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE';
// Annotations
-export const REQUEST_ANNOTATIONS = 'REQUEST_ANNOTATIONS';
export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS';
export const RECEIVE_ANNOTATIONS_FAILURE = 'RECEIVE_ANNOTATIONS_FAILURE';
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index a212e9be703..9f06d18c46f 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -58,6 +58,31 @@ export const parseEnvironmentsResponse = (response = [], projectPath) =>
});
/**
+ * Annotation API returns time in UTC. This method
+ * converts time to local time.
+ *
+ * startingAt always exists but endingAt does not.
+ * If endingAt does not exist, a threshold line is
+ * drawn.
+ *
+ * If endingAt exists, a threshold range is drawn.
+ * But this is not supported as of %12.10
+ *
+ * @param {Array} response annotations response
+ * @returns {Array} parsed responses
+ */
+export const parseAnnotationsResponse = response => {
+ if (!response) {
+ return [];
+ }
+ return response.map(annotation => ({
+ ...annotation,
+ startingAt: new Date(annotation.startingAt),
+ endingAt: annotation.endingAt ? new Date(annotation.endingAt) : null,
+ }));
+};
+
+/**
* Maps metrics to its view model
*
* This function difers from other in that is maps all
@@ -95,15 +120,19 @@ const mapXAxisToViewModel = ({ name = '' }) => ({ name });
/**
* Maps Y-axis view model
*
- * Defaults to a 2 digit precision and `number` format. It only allows
+ * Defaults to a 2 digit precision and `engineering` format. It only allows
* formats in the SUPPORTED_FORMATS array.
*
* @param {Object} axis
*/
-const mapYAxisToViewModel = ({ name = '', format = SUPPORTED_FORMATS.number, precision = 2 }) => {
+const mapYAxisToViewModel = ({
+ name = '',
+ format = SUPPORTED_FORMATS.engineering,
+ precision = 2,
+}) => {
return {
name,
- format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.number,
+ format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.engineering,
precision,
};
};
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index df62e379017..5181b5f26ee 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -1,17 +1,12 @@
<script>
import { mapActions, mapGetters } from 'vuex';
-import tooltip from '~/vue_shared/directives/tooltip';
-import Icon from '~/vue_shared/components/icon.vue';
+import AwardsList from '~/vue_shared/components/awards_list.vue';
import Flash from '../../flash';
-import { glEmojiTag } from '../../emoji';
-import { __, sprintf } from '~/locale';
+import { __ } from '~/locale';
export default {
components: {
- Icon,
- },
- directives: {
- tooltip,
+ AwardsList,
},
props: {
awards: {
@@ -37,130 +32,20 @@ export default {
},
computed: {
...mapGetters(['getUserData']),
- // `this.awards` is an array with emojis but they are not grouped by emoji name. See below.
- // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ]
- // This method will group emojis by their name as an Object. See below.
- // {
- // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ],
- // bar: [ { name: bar, user: user1 } ]
- // }
- // We need to do this otherwise we will render the same emoji over and over again.
- groupedAwards() {
- const awards = this.awards.reduce((acc, award) => {
- if (Object.prototype.hasOwnProperty.call(acc, award.name)) {
- acc[award.name].push(award);
- } else {
- Object.assign(acc, { [award.name]: [award] });
- }
-
- return acc;
- }, {});
-
- const orderedAwards = {};
- const { thumbsdown, thumbsup } = awards;
- // Always show thumbsup and thumbsdown first
- if (thumbsup) {
- orderedAwards.thumbsup = thumbsup;
- delete awards.thumbsup;
- }
- if (thumbsdown) {
- orderedAwards.thumbsdown = thumbsdown;
- delete awards.thumbsdown;
- }
-
- return Object.assign({}, orderedAwards, awards);
- },
isAuthoredByMe() {
return this.noteAuthorId === this.getUserData.id;
},
+ addButtonClass() {
+ return this.isAuthoredByMe ? 'js-user-authored' : '';
+ },
},
methods: {
...mapActions(['toggleAwardRequest']),
- getAwardHTML(name) {
- return glEmojiTag(name);
- },
- getAwardClassBindings(awardList) {
- return {
- active: this.hasReactionByCurrentUser(awardList),
- disabled: !this.canInteractWithEmoji(),
- };
- },
- canInteractWithEmoji() {
- return this.getUserData.id;
- },
- hasReactionByCurrentUser(awardList) {
- return awardList.filter(award => award.user.id === this.getUserData.id).length;
- },
- awardTitle(awardsList) {
- const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList);
- const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
- let awardList = awardsList;
-
- // Filter myself from list if I am awarded.
- if (hasReactionByCurrentUser) {
- awardList = awardList.filter(award => award.user.id !== this.getUserData.id);
- }
-
- // Get only 9-10 usernames to show in tooltip text.
- const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name);
-
- // Get the remaining list to use in `and x more` text.
- const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length);
-
- // Add myself to the beginning of the list so title will start with You.
- if (hasReactionByCurrentUser) {
- namesToShow.unshift(__('You'));
- }
-
- let title = '';
-
- // We have 10+ awarded user, join them with comma and add `and x more`.
- if (remainingAwardList.length) {
- title = sprintf(
- __(`%{listToShow}, and %{awardsListLength} more.`),
- {
- listToShow: namesToShow.join(', '),
- awardsListLength: remainingAwardList.length,
- },
- false,
- );
- } else if (namesToShow.length > 1) {
- // Join all names with comma but not the last one, it will be added with and text.
- title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
- // If we have more than 2 users we need an extra comma before and text.
- title += namesToShow.length > 2 ? ',' : '';
- title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text
- } else {
- // We have only 2 users so join them with and.
- title = namesToShow.join(__(' and '));
- }
-
- return title;
- },
handleAward(awardName) {
- if (!this.canAwardEmoji) {
- return;
- }
-
- let parsedName;
-
- // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string
- switch (awardName) {
- case '100':
- parsedName = 100;
- break;
- case '1234':
- parsedName = 1234;
- break;
- default:
- parsedName = awardName;
- break;
- }
-
const data = {
endpoint: this.toggleAwardPath,
noteId: this.noteId,
- awardName: parsedName,
+ awardName,
};
this.toggleAwardRequest(data).catch(() => Flash(__('Something went wrong on our end.')));
@@ -171,46 +56,12 @@ export default {
<template>
<div class="note-awards">
- <div class="awards js-awards-block">
- <button
- v-for="(awardList, awardName, index) in groupedAwards"
- :key="index"
- v-tooltip
- :class="getAwardClassBindings(awardList)"
- :title="awardTitle(awardList)"
- data-boundary="viewport"
- class="btn award-control"
- type="button"
- @click="handleAward(awardName)"
- >
- <span v-html="getAwardHTML(awardName)"></span>
- <span class="award-control-text js-counter">{{ awardList.length }}</span>
- </button>
- <div v-if="canAwardEmoji" class="award-menu-holder">
- <button
- v-tooltip
- :class="{ 'js-user-authored': isAuthoredByMe }"
- class="award-control btn js-add-award"
- title="Add reaction"
- :aria-label="__('Add reaction')"
- data-boundary="viewport"
- type="button"
- >
- <span class="award-control-icon award-control-icon-neutral">
- <icon name="slight-smile" />
- </span>
- <span class="award-control-icon award-control-icon-positive">
- <icon name="smiley" />
- </span>
- <span class="award-control-icon award-control-icon-super-positive">
- <icon name="smiley" />
- </span>
- <i
- aria-hidden="true"
- class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"
- ></i>
- </button>
- </div>
- </div>
+ <awards-list
+ :awards="awards"
+ :can-award-emoji="canAwardEmoji"
+ :current-user-id="getUserData.id"
+ :add-button-class="addButtonClass"
+ @award="handleAward($event)"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js
index c40503603be..bbaaeb55c65 100644
--- a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js
@@ -1,8 +1,3 @@
-import UsagePingPayload from './../usage_ping_payload';
+import setup from 'ee_else_ce/admin/application_settings/setup_metrics_and_profiling';
-document.addEventListener('DOMContentLoaded', () => {
- new UsagePingPayload(
- document.querySelector('.js-usage-ping-payload-trigger'),
- document.querySelector('.js-usage-ping-payload'),
- ).init();
-});
+document.addEventListener('DOMContentLoaded', setup);
diff --git a/app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
index 95f4ba28b42..413045d960e 100644
--- a/app/assets/javascripts/pages/admin/application_settings/usage_ping_payload.js
+++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js
@@ -2,7 +2,7 @@ import axios from '../../../lib/utils/axios_utils';
import { __ } from '../../../locale';
import flash from '../../../flash';
-export default class UsagePingPayload {
+export default class PayloadPreviewer {
constructor(trigger, container) {
this.trigger = trigger;
this.container = container;
@@ -38,7 +38,7 @@ export default class UsagePingPayload {
})
.catch(() => {
this.spinner.classList.remove('d-inline-flex');
- flash(__('Error fetching usage ping data.'));
+ flash(__('Error fetching payload data.'));
});
}
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index a3743ded601..6efddec1172 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -3,6 +3,7 @@ import { GlSprintf, GlLink } from '@gitlab/ui';
import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin';
import { s__ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import projectFeatureSetting from './project_feature_setting.vue';
import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue';
import projectSettingRow from './project_setting_row.vue';
@@ -24,7 +25,7 @@ export default {
GlSprintf,
GlLink,
},
- mixins: [settingsMixin],
+ mixins: [settingsMixin, glFeatureFlagsMixin()],
props: {
currentSettings: {
@@ -116,6 +117,8 @@ export default {
const defaults = {
visibilityOptions,
visibilityLevel: visibilityOptions.PUBLIC,
+ // TODO: Change all of these to use the visibilityOptions constants
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/214667
issuesAccessLevel: 20,
repositoryAccessLevel: 20,
forkingAccessLevel: 20,
@@ -124,11 +127,14 @@ export default {
wikiAccessLevel: 20,
snippetsAccessLevel: 20,
pagesAccessLevel: 20,
+ metricsAccessLevel: visibilityOptions.PRIVATE,
containerRegistryEnabled: true,
lfsEnabled: true,
requestAccessEnabled: true,
highlightChangesClass: false,
emailsDisabled: false,
+ featureAccessLevelEveryone,
+ featureAccessLevelMembers,
};
return { ...defaults, ...this.currentSettings };
@@ -189,6 +195,10 @@ export default {
'ProjectSettings|View and edit files in this project. Non-project members will only have read access',
);
},
+
+ metricsDashboardVisibilitySwitchingAvailable() {
+ return this.glFeatures.metricsDashboardVisibilitySwitchingAvailable;
+ },
},
watch: {
@@ -462,6 +472,38 @@ export default {
name="project[project_feature_attributes][pages_access_level]"
/>
</project-setting-row>
+ <project-setting-row
+ v-if="metricsDashboardVisibilitySwitchingAvailable"
+ ref="metrics-visibility-settings"
+ :label="__('Metrics Dashboard')"
+ :help-text="
+ s__(
+ 'ProjectSettings|With Metrics Dashboard you can visualize this project performance metrics',
+ )
+ "
+ >
+ <div class="project-feature-controls">
+ <div class="select-wrapper">
+ <select
+ v-model="metricsAccessLevel"
+ name="project[project_feature_attributes][metrics_dashboard_access_level]"
+ class="form-control select-control"
+ >
+ <option
+ :value="visibilityOptions.PRIVATE"
+ :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)"
+ >{{ featureAccessLevelMembers[1] }}</option
+ >
+ <option
+ :value="visibilityOptions.PUBLIC"
+ :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)"
+ >{{ featureAccessLevelEveryone[1] }}</option
+ >
+ </select>
+ <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
+ </div>
+ </div>
+ </project-setting-row>
</div>
<project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3">
<label class="js-emails-disabled">
diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js
index e5898c3b047..2d321ead33e 100644
--- a/app/assets/javascripts/projects/default_project_templates.js
+++ b/app/assets/javascripts/projects/default_project_templates.js
@@ -53,6 +53,10 @@ export default {
text: s__('ProjectTemplates|Pages/Hexo'),
icon: '.template-option .icon-hexo',
},
+ sse_middleman: {
+ text: s__('ProjectTemplates|Static Site Editor/Middleman'),
+ icon: '.template-option .icon-sse_middleman',
+ },
nfhugo: {
text: s__('ProjectTemplates|Netlify/Hugo'),
icon: '.template-option .icon-nfhugo',
diff --git a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue
index 6acf366e531..88a0710574f 100644
--- a/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue
+++ b/app/assets/javascripts/registry/explorer/components/project_policy_alert.vue
@@ -53,7 +53,6 @@ export default {
:primary-button-text="alertConfiguration.primaryButton"
:primary-button-link="config.settingsPath"
:title="alertConfiguration.title"
- class="my-2"
>
<gl-sprintf :message="alertConfiguration.message">
<template #days>
diff --git a/app/assets/javascripts/registry/explorer/constants.js b/app/assets/javascripts/registry/explorer/constants.js
index 586231d19c7..d4b9d25b212 100644
--- a/app/assets/javascripts/registry/explorer/constants.js
+++ b/app/assets/javascripts/registry/explorer/constants.js
@@ -1,16 +1,44 @@
import { s__ } from '~/locale';
+// List page
+
+export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry');
+export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error');
+export const CONNECTION_ERROR_MESSAGE = s__(
+ `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`,
+);
+export const LIST_INTRO_TEXT = s__(
+ `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`,
+);
+
+export const LIST_DELETE_BUTTON_DISABLED = s__(
+ 'ContainerRegistry|Missing or insufficient permission, delete button disabled',
+);
+export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository');
+export const REMOVE_REPOSITORY_MODAL_TEXT = s__(
+ 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
+);
+export const ROW_SCHEDULED_FOR_DELETION = s__(
+ `ContainerRegistry|This image repository is scheduled for deletion`,
+);
export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while fetching the packages list.',
+ 'ContainerRegistry|Something went wrong while fetching the repository list.',
);
export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while fetching the tags list.',
);
-
export const DELETE_IMAGE_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while deleting the image.',
+ 'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.',
);
-export const DELETE_IMAGE_SUCCESS_MESSAGE = s__('ContainerRegistry|Image deleted successfully');
+export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__(
+ `ContainerRegistry|There was an error during the deletion of this image repository, please try again.`,
+);
+export const DELETE_IMAGE_SUCCESS_MESSAGE = s__(
+ 'ContainerRegistry|%{title} was successfully scheduled for deletion',
+);
+
+// Image details page
+
export const DELETE_TAG_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while deleting the tag.',
);
@@ -37,6 +65,8 @@ export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID');
export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size');
export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated');
+// Expiration policies
+
export const EXPIRATION_POLICY_ALERT_TITLE = s__(
'ContainerRegistry|Retention policy has been Enabled',
);
@@ -48,6 +78,8 @@ export const EXPIRATION_POLICY_ALERT_SHORT_MESSAGE = s__(
'ContainerRegistry|The retention and expiration policy for this Container Registry has been enabled. For more information visit the %{linkStart}documentation%{linkEnd}',
);
+// Quick Start
+
export const QUICK_START = s__('ContainerRegistry|Quick Start');
export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login');
export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command');
@@ -55,3 +87,8 @@ export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image');
export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command');
export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image');
export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command');
+
+// Image state
+
+export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled';
+export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed';
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
index 7204cbd90eb..8923c305b2d 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -9,16 +9,28 @@ import {
GlModal,
GlSprintf,
GlLink,
+ GlAlert,
GlSkeletonLoader,
} from '@gitlab/ui';
import Tracking from '~/tracking';
-import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ProjectEmptyState from '../components/project_empty_state.vue';
import GroupEmptyState from '../components/group_empty_state.vue';
import ProjectPolicyAlert from '../components/project_policy_alert.vue';
import QuickstartDropdown from '../components/quickstart_dropdown.vue';
-import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE } from '../constants';
+import {
+ DELETE_IMAGE_SUCCESS_MESSAGE,
+ DELETE_IMAGE_ERROR_MESSAGE,
+ ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
+ CONTAINER_REGISTRY_TITLE,
+ CONNECTION_ERROR_TITLE,
+ CONNECTION_ERROR_MESSAGE,
+ LIST_INTRO_TEXT,
+ LIST_DELETE_BUTTON_DISABLED,
+ REMOVE_REPOSITORY_LABEL,
+ REMOVE_REPOSITORY_MODAL_TEXT,
+ ROW_SCHEDULED_FOR_DELETION,
+} from '../constants';
export default {
name: 'RegistryListApp',
@@ -35,6 +47,7 @@ export default {
GlModal,
GlSprintf,
GlLink,
+ GlAlert,
GlSkeletonLoader,
},
directives: {
@@ -47,25 +60,20 @@ export default {
height: 40,
},
i18n: {
- containerRegistryTitle: s__('ContainerRegistry|Container Registry'),
- connectionErrorTitle: s__('ContainerRegistry|Docker connection error'),
- connectionErrorMessage: s__(
- `ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}`,
- ),
- introText: s__(
- `ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`,
- ),
- deleteButtonDisabled: s__(
- 'ContainerRegistry|Missing or insufficient permission, delete button disabled',
- ),
- removeRepositoryLabel: s__('ContainerRegistry|Remove repository'),
- removeRepositoryModalText: s__(
- 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
- ),
+ containerRegistryTitle: CONTAINER_REGISTRY_TITLE,
+ connectionErrorTitle: CONNECTION_ERROR_TITLE,
+ connectionErrorMessage: CONNECTION_ERROR_MESSAGE,
+ introText: LIST_INTRO_TEXT,
+ deleteButtonDisabled: LIST_DELETE_BUTTON_DISABLED,
+ removeRepositoryLabel: REMOVE_REPOSITORY_LABEL,
+ removeRepositoryModalText: REMOVE_REPOSITORY_MODAL_TEXT,
+ rowScheduledForDeletion: ROW_SCHEDULED_FOR_DELETION,
+ asyncDeleteErrorMessage: ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
},
data() {
return {
itemToDelete: {},
+ deleteAlertType: null,
};
},
computed: {
@@ -86,43 +94,61 @@ export default {
showQuickStartDropdown() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
},
+ showDeleteAlert() {
+ return this.deleteAlertType && this.itemToDelete?.path;
+ },
+ deleteImageAlertMessage() {
+ return this.deleteAlertType === 'success'
+ ? DELETE_IMAGE_SUCCESS_MESSAGE
+ : DELETE_IMAGE_ERROR_MESSAGE;
+ },
},
methods: {
...mapActions(['requestImagesList', 'requestDeleteImage']),
deleteImage(item) {
- // This event is already tracked in the system and so the name must be kept to aggregate the data
this.track('click_button');
this.itemToDelete = item;
this.$refs.deleteModal.show();
},
handleDeleteImage() {
this.track('confirm_delete');
- return this.requestDeleteImage(this.itemToDelete.destroy_path)
- .then(() =>
- this.$toast.show(DELETE_IMAGE_SUCCESS_MESSAGE, {
- type: 'success',
- }),
- )
- .catch(() =>
- this.$toast.show(DELETE_IMAGE_ERROR_MESSAGE, {
- type: 'error',
- }),
- )
- .finally(() => {
- this.itemToDelete = {};
+ return this.requestDeleteImage(this.itemToDelete)
+ .then(() => {
+ this.deleteAlertType = 'success';
+ })
+ .catch(() => {
+ this.deleteAlertType = 'danger';
});
},
encodeListItem(item) {
const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id });
return window.btoa(params);
},
+ dismissDeleteAlert() {
+ this.deleteAlertType = null;
+ this.itemToDelete = {};
+ },
},
};
</script>
<template>
<div class="w-100 slide-enter-from-element">
- <project-policy-alert v-if="!config.isGroupPage" />
+ <gl-alert
+ v-if="showDeleteAlert"
+ :variant="deleteAlertType"
+ class="mt-2"
+ dismissible
+ @dismiss="dismissDeleteAlert"
+ >
+ <gl-sprintf :message="deleteImageAlertMessage">
+ <template #title>
+ {{ itemToDelete.path }}
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
+ <project-policy-alert v-if="!config.isGroupPage" class="mt-2" />
<gl-empty-state
v-if="config.characterError"
@@ -178,41 +204,57 @@ export default {
v-for="(listItem, index) in images"
:key="index"
ref="rowItem"
- :class="{ 'border-top': index === 0 }"
- class="d-flex justify-content-between align-items-center py-2 border-bottom"
+ v-gl-tooltip="{
+ placement: 'left',
+ disabled: !listItem.deleting,
+ title: $options.i18n.rowScheduledForDeletion,
+ }"
>
- <div>
- <router-link
- ref="detailsLink"
- :to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
- >
- {{ listItem.path }}
- </router-link>
- <clipboard-button
- v-if="listItem.location"
- ref="clipboardButton"
- :text="listItem.location"
- :title="listItem.location"
- css-class="btn-default btn-transparent btn-clipboard"
- />
- </div>
<div
- v-gl-tooltip="{ disabled: listItem.destroy_path }"
- class="d-none d-sm-block"
- :title="$options.i18n.deleteButtonDisabled"
+ class="d-flex justify-content-between align-items-center py-2 px-1 border-bottom"
+ :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }"
>
- <gl-deprecated-button
- ref="deleteImageButton"
- v-gl-tooltip
- :disabled="!listItem.destroy_path"
- :title="$options.i18n.removeRepositoryLabel"
- :aria-label="$options.i18n.removeRepositoryLabel"
- class="btn-inverted"
- variant="danger"
- @click="deleteImage(listItem)"
+ <div class="d-felx align-items-center">
+ <router-link
+ ref="detailsLink"
+ :to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
+ >
+ {{ listItem.path }}
+ </router-link>
+ <clipboard-button
+ v-if="listItem.location"
+ ref="clipboardButton"
+ :disabled="listItem.deleting"
+ :text="listItem.location"
+ :title="listItem.location"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+ <gl-icon
+ v-if="listItem.failedDelete"
+ v-gl-tooltip
+ :title="$options.i18n.asyncDeleteErrorMessage"
+ name="warning"
+ class="text-warning align-middle"
+ />
+ </div>
+ <div
+ v-gl-tooltip="{ disabled: listItem.destroy_path }"
+ class="d-none d-sm-block"
+ :title="$options.i18n.deleteButtonDisabled"
>
- <gl-icon name="remove" />
- </gl-deprecated-button>
+ <gl-deprecated-button
+ ref="deleteImageButton"
+ v-gl-tooltip
+ :disabled="!listItem.destroy_path || listItem.deleting"
+ :title="$options.i18n.removeRepositoryLabel"
+ :aria-label="$options.i18n.removeRepositoryLabel"
+ class="btn-inverted"
+ variant="danger"
+ @click="deleteImage(listItem)"
+ >
+ <gl-icon name="remove" />
+ </gl-deprecated-button>
+ </div>
</div>
</div>
<gl-pagination
diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js
index 2abd72cb9a8..b4f66dbbcd6 100644
--- a/app/assets/javascripts/registry/explorer/stores/actions.js
+++ b/app/assets/javascripts/registry/explorer/stores/actions.js
@@ -88,14 +88,12 @@ export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params })
});
};
-export const requestDeleteImage = ({ commit, dispatch, state }, destroyPath) => {
+export const requestDeleteImage = ({ commit }, image) => {
commit(types.SET_MAIN_LOADING, true);
-
return axios
- .delete(destroyPath)
+ .delete(image.destroy_path)
.then(() => {
- dispatch('setShowGarbageCollectionTip', true);
- dispatch('requestImagesList', { pagination: state.pagination });
+ commit(types.UPDATE_IMAGE, { ...image, deleting: true });
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false);
diff --git a/app/assets/javascripts/registry/explorer/stores/mutation_types.js b/app/assets/javascripts/registry/explorer/stores/mutation_types.js
index 86eaa0dd2f1..f32cdf90783 100644
--- a/app/assets/javascripts/registry/explorer/stores/mutation_types.js
+++ b/app/assets/javascripts/registry/explorer/stores/mutation_types.js
@@ -1,6 +1,7 @@
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS';
+export const UPDATE_IMAGE = 'UPDATE_IMAGE';
export const SET_PAGINATION = 'SET_PAGINATION';
export const SET_MAIN_LOADING = 'SET_MAIN_LOADING';
export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION';
diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js
index fda788051c0..b25a0221dc1 100644
--- a/app/assets/javascripts/registry/explorer/stores/mutations.js
+++ b/app/assets/javascripts/registry/explorer/stores/mutations.js
@@ -1,5 +1,6 @@
import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants';
export default {
[types.SET_INITIAL_STATE](state, config) {
@@ -12,7 +13,17 @@ export default {
},
[types.SET_IMAGES_LIST_SUCCESS](state, images) {
- state.images = images;
+ state.images = images.map(i => ({
+ ...i,
+ status: undefined,
+ deleting: i.status === IMAGE_DELETE_SCHEDULED_STATUS,
+ failedDelete: i.status === IMAGE_FAILED_DELETED_STATUS,
+ }));
+ },
+
+ [types.UPDATE_IMAGE](state, image) {
+ const index = state.images.findIndex(i => i.id === image.id);
+ state.images.splice(index, 1, { ...image });
},
[types.SET_TAGS_LIST_SUCCESS](state, tags) {
diff --git a/app/assets/javascripts/releases/components/app_edit.vue b/app/assets/javascripts/releases/components/app_edit.vue
index df356c18417..8d68ff02116 100644
--- a/app/assets/javascripts/releases/components/app_edit.vue
+++ b/app/assets/javascripts/releases/components/app_edit.vue
@@ -1,6 +1,6 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
-import { GlNewButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
+import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
import { escape as esc } from 'lodash';
import { __, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
@@ -15,7 +15,7 @@ export default {
components: {
GlFormInput,
GlFormGroup,
- GlNewButton,
+ GlButton,
MarkdownField,
AssetLinksForm,
},
@@ -167,7 +167,7 @@ export default {
<asset-links-form v-if="showAssetLinksForm" />
<div class="d-flex pt-3">
- <gl-new-button
+ <gl-button
class="mr-auto js-no-auto-disable"
category="primary"
variant="success"
@@ -176,10 +176,10 @@ export default {
:disabled="isSaveChangesDisabled"
>
{{ __('Save changes') }}
- </gl-new-button>
- <gl-new-button :href="cancelPath" class="js-cancel-button">
+ </gl-button>
+ <gl-button :href="cancelPath" class="js-cancel-button">
{{ __('Cancel') }}
- </gl-new-button>
+ </gl-button>
</div>
</form>
</div>
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index 6ca700c2b30..4bdc88f01dd 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -4,7 +4,7 @@ import {
GlSprintf,
GlLink,
GlFormGroup,
- GlNewButton,
+ GlButton,
GlIcon,
GlTooltipDirective,
GlFormInput,
@@ -12,7 +12,7 @@ import {
export default {
name: 'AssetLinksForm',
- components: { GlSprintf, GlLink, GlFormGroup, GlNewButton, GlIcon, GlFormInput },
+ components: { GlSprintf, GlLink, GlFormGroup, GlButton, GlIcon, GlFormInput },
directives: { GlTooltip: GlTooltipDirective },
computed: {
...mapState('detail', ['release', 'releaseAssetsDocsPath']),
@@ -170,7 +170,7 @@ export default {
</gl-form-group>
<div class="mb-5 mb-sm-3 mt-sm-4 col col-sm-auto">
- <gl-new-button
+ <gl-button
v-gl-tooltip
class="remove-button w-100"
:aria-label="__('Remove asset link')"
@@ -179,16 +179,16 @@ export default {
>
<gl-icon class="mr-1 mr-sm-0 mb-1" :size="16" name="remove" />
<span class="d-inline d-sm-none">{{ __('Remove asset link') }}</span>
- </gl-new-button>
+ </gl-button>
</div>
</div>
- <gl-new-button
+ <gl-button
ref="addAnotherLinkButton"
variant="link"
class="align-self-end mb-5 mb-sm-0"
@click="onAddAnotherClicked"
>
{{ __('Add another link') }}
- </gl-new-button>
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 6c58f48dc74..fdd6b4eb87a 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -108,14 +108,14 @@ export default {
return acc.concat({
name,
path,
- to: `/-/tree/${joinPaths(escape(this.ref), path)}`,
+ to: `/-/tree/${joinPaths(encodeURIComponent(this.ref), path)}`,
});
},
[
{
name: this.projectShortPath,
path: '/',
- to: `/-/tree/${escape(this.ref)}/`,
+ to: `/-/tree/${encodeURIComponent(this.ref)}/`,
},
],
);
diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue
index f9fcbc356e8..32bdda2e0a8 100644
--- a/app/assets/javascripts/repository/components/table/parent_row.vue
+++ b/app/assets/javascripts/repository/components/table/parent_row.vue
@@ -28,7 +28,7 @@ export default {
return splitArray.map(p => encodeURIComponent(p)).join('/');
},
parentRoute() {
- return { path: `/-/tree/${escape(this.commitRef)}/${this.parentPath}` };
+ return { path: `/-/tree/${encodeURIComponent(this.commitRef)}/${this.parentPath}` };
},
},
methods: {
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 00ccc49d770..d9ef6eec6f1 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -99,7 +99,7 @@ export default {
computed: {
routerLinkTo() {
return this.isFolder
- ? { path: `/-/tree/${escape(this.ref)}/${escapeFileUrl(this.path)}` }
+ ? { path: `/-/tree/${encodeURIComponent(this.ref)}/${escapeFileUrl(this.path)}` }
: null;
},
isFolder() {
diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js
index 0c68b5a599b..6640b636597 100644
--- a/app/assets/javascripts/repository/graphql.js
+++ b/app/assets/javascripts/repository/graphql.js
@@ -48,7 +48,7 @@ const defaultClient = createDefaultClient(
case 'TreeEntry':
case 'Submodule':
case 'Blob':
- return `${escape(obj.flatPath)}-${obj.id}`;
+ return `${encodeURIComponent(obj.flatPath)}-${obj.id}`;
default:
// If the type doesn't match any of the above we fallback
// to using the default Apollo ID
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 637060f6ed9..05783fc3b5d 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -100,7 +100,9 @@ export default function setupVueRepositoryList() {
render(h) {
return h(TreeActionLink, {
props: {
- path: `${historyLink}/${this.$route.params.path ? escape(this.$route.params.path) : ''}`,
+ path: `${historyLink}/${
+ this.$route.params.path ? encodeURIComponent(this.$route.params.path) : ''
+ }`,
text: __('History'),
},
});
diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js
index b2636f910fe..d74447dd566 100644
--- a/app/assets/javascripts/repository/router.js
+++ b/app/assets/javascripts/repository/router.js
@@ -12,7 +12,7 @@ export default function createRouter(base, baseRef) {
base: joinPaths(gon.relative_url_root || '', base),
routes: [
{
- path: `(/-)?/tree/(${encodeURIComponent(baseRef).replace(/%2F/g, '/')}|${baseRef})/:path*`,
+ path: `(/-)?/tree/(${encodeURIComponent(baseRef)}|${baseRef})/:path*`,
name: 'treePath',
component: TreePage,
props: route => ({
diff --git a/app/assets/javascripts/snippet/snippet_edit.js b/app/assets/javascripts/snippet/snippet_edit.js
index a098d17a226..b0d373b1a4b 100644
--- a/app/assets/javascripts/snippet/snippet_edit.js
+++ b/app/assets/javascripts/snippet/snippet_edit.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import initSnippet from '~/snippet/snippet_bundle';
import ZenMode from '~/zen_mode';
import GLForm from '~/gl_form';
+import { SnippetEditInit } from '~/snippets';
document.addEventListener('DOMContentLoaded', () => {
const form = document.querySelector('.snippet-form');
@@ -17,9 +18,15 @@ document.addEventListener('DOMContentLoaded', () => {
const projectSnippetOptions = {};
const options =
- form.dataset.snippetType === 'project' ? projectSnippetOptions : personalSnippetOptions;
+ form.dataset.snippetType === 'project' || form.dataset.projectPath
+ ? projectSnippetOptions
+ : personalSnippetOptions;
- initSnippet();
+ if (gon?.features?.snippetsEditVue) {
+ SnippetEditInit();
+ } else {
+ initSnippet();
+ new GLForm($(form), options); // eslint-disable-line no-new
+ }
new ZenMode(); // eslint-disable-line no-new
- new GLForm($(form), options); // eslint-disable-line no-new
});
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
new file mode 100644
index 00000000000..2185b1d67e4
--- /dev/null
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -0,0 +1,211 @@
+<script>
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+
+import Flash from '~/flash';
+import { __, sprintf } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import TitleField from '~/vue_shared/components/form/title.vue';
+import { getBaseURL, joinPaths, redirectTo } from '~/lib/utils/url_utility';
+import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
+
+import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql';
+import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql';
+import { getSnippetMixin } from '../mixins/snippets';
+import { SNIPPET_VISIBILITY_PRIVATE } from '../constants';
+import SnippetBlobEdit from './snippet_blob_edit.vue';
+import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
+import SnippetDescriptionEdit from './snippet_description_edit.vue';
+
+export default {
+ components: {
+ SnippetDescriptionEdit,
+ SnippetVisibilityEdit,
+ SnippetBlobEdit,
+ TitleField,
+ FormFooterActions,
+ GlButton,
+ GlLoadingIcon,
+ },
+ mixins: [getSnippetMixin],
+ props: {
+ markdownPreviewPath: {
+ type: String,
+ required: true,
+ },
+ markdownDocsPath: {
+ type: String,
+ required: true,
+ },
+ visibilityHelpLink: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ projectPath: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ },
+ data() {
+ return {
+ blob: {},
+ fileName: '',
+ content: '',
+ isContentLoading: true,
+ isUpdating: false,
+ newSnippet: false,
+ };
+ },
+ computed: {
+ updatePrevented() {
+ return this.snippet.title === '' || this.content === '' || this.isUpdating;
+ },
+ isProjectSnippet() {
+ return Boolean(this.projectPath);
+ },
+ apiData() {
+ return {
+ id: this.snippet.id,
+ title: this.snippet.title,
+ description: this.snippet.description,
+ visibilityLevel: this.snippet.visibilityLevel,
+ fileName: this.fileName,
+ content: this.content,
+ };
+ },
+ saveButtonLabel() {
+ if (this.newSnippet) {
+ return __('Create snippet');
+ }
+ return this.isUpdating ? __('Saving') : __('Save changes');
+ },
+ cancelButtonHref() {
+ return this.projectPath ? `/${this.projectPath}/snippets` : `/snippets`;
+ },
+ titleFieldId() {
+ return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_title`;
+ },
+ descriptionFieldId() {
+ return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_description`;
+ },
+ },
+ methods: {
+ updateFileName(newName) {
+ this.fileName = newName;
+ },
+ flashAPIFailure(err) {
+ Flash(sprintf(__("Can't update snippet: %{err}"), { err }));
+ },
+ onNewSnippetFetched() {
+ this.newSnippet = true;
+ this.snippet = this.$options.newSnippetSchema;
+ this.blob = this.snippet.blob;
+ this.isContentLoading = false;
+ },
+ onExistingSnippetFetched() {
+ this.newSnippet = false;
+ const { blob } = this.snippet;
+ this.blob = blob;
+ this.fileName = blob.name;
+ const baseUrl = getBaseURL();
+ const url = joinPaths(baseUrl, blob.rawPath);
+
+ axios
+ .get(url)
+ .then(res => {
+ this.content = res.data;
+ this.isContentLoading = false;
+ })
+ .catch(e => this.flashAPIFailure(e));
+ },
+ onSnippetFetch(snippetRes) {
+ if (snippetRes.data.snippets.edges.length === 0) {
+ this.onNewSnippetFetched();
+ } else {
+ this.onExistingSnippetFetched();
+ }
+ },
+ handleFormSubmit() {
+ this.isUpdating = true;
+ this.$apollo
+ .mutate({
+ mutation: this.newSnippet ? CreateSnippetMutation : UpdateSnippetMutation,
+ variables: {
+ input: {
+ ...this.apiData,
+ projectPath: this.newSnippet ? this.projectPath : undefined,
+ },
+ },
+ })
+ .then(({ data }) => {
+ const baseObj = this.newSnippet ? data?.createSnippet : data?.updateSnippet;
+
+ const errors = baseObj?.errors;
+ if (errors.length) {
+ this.flashAPIFailure(errors[0]);
+ }
+ redirectTo(baseObj.snippet.webUrl);
+ })
+ .catch(e => {
+ this.isUpdating = false;
+ this.flashAPIFailure(e);
+ });
+ },
+ },
+ newSnippetSchema: {
+ title: '',
+ description: '',
+ visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
+ blob: {},
+ },
+};
+</script>
+<template>
+ <form
+ class="snippet-form js-requires-input js-quick-submit common-note-form"
+ :data-snippet-type="isProjectSnippet ? 'project' : 'personal'"
+ >
+ <gl-loading-icon
+ v-if="isLoading"
+ :label="__('Loading snippet')"
+ size="lg"
+ class="loading-animation prepend-top-20 append-bottom-20"
+ />
+ <template v-else>
+ <title-field :id="titleFieldId" v-model="snippet.title" required :autofocus="true" />
+ <snippet-description-edit
+ :id="descriptionFieldId"
+ v-model="snippet.description"
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath"
+ />
+ <snippet-blob-edit
+ v-model="content"
+ :file-name="fileName"
+ :is-loading="isContentLoading"
+ @name-change="updateFileName"
+ />
+ <snippet-visibility-edit
+ v-model="snippet.visibilityLevel"
+ :help-link="visibilityHelpLink"
+ :is-project-snippet="isProjectSnippet"
+ />
+ <form-footer-actions>
+ <template #prepend>
+ <gl-button
+ type="submit"
+ category="primary"
+ variant="success"
+ :disabled="updatePrevented"
+ @click="handleFormSubmit"
+ >{{ saveButtonLabel }}</gl-button
+ >
+ </template>
+ <template #append>
+ <gl-button :href="cancelButtonHref">{{ __('Cancel') }}</gl-button>
+ </template>
+ </form-footer-actions>
+ </template>
+ </form>
+</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
index 68810f8ab3f..6f3a86be8d7 100644
--- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
@@ -50,7 +50,6 @@ export default {
:markdown-docs-path="markdownDocsPath"
>
<textarea
- id="snippet-description"
slot="textarea"
class="note-textarea js-gfm-input js-autosize markdown-area
qa-description-textarea"
@@ -59,6 +58,7 @@ export default {
:value="value"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
+ v-bind="$attrs"
@input="$emit('input', $event.target.value)"
>
</textarea>
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index 79b191cb25a..30a23b51bc4 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -9,7 +9,7 @@ import {
GlLoadingIcon,
GlDropdown,
GlDropdownItem,
- GlNewButton,
+ GlButton,
} from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -28,7 +28,7 @@ export default {
GlDropdown,
GlDropdownItem,
TimeAgoTooltip,
- GlNewButton,
+ GlButton,
},
apollo: {
canCreateSnippet: {
@@ -186,7 +186,7 @@ export default {
<div class="detail-page-header-actions">
<div class="d-none d-sm-flex">
<template v-for="(action, index) in personalSnippetActions">
- <gl-new-button
+ <gl-button
v-if="action.condition"
:key="index"
:disabled="action.disabled"
@@ -197,7 +197,7 @@ export default {
@click="action.click ? action.click() : undefined"
>
{{ action.text }}
- </gl-new-button>
+ </gl-button>
</template>
</div>
<div class="d-block d-sm-none dropdown">
@@ -227,8 +227,8 @@ export default {
</gl-sprintf>
<template #modal-footer>
- <gl-new-button @click="closeDeleteModal">{{ __('Cancel') }}</gl-new-button>
- <gl-new-button
+ <gl-button @click="closeDeleteModal">{{ __('Cancel') }}</gl-button>
+ <gl-button
variant="danger"
category="primary"
:disabled="isDeleting"
@@ -237,7 +237,7 @@ export default {
>
<gl-loading-icon v-if="isDeleting" inline />
{{ __('Delete snippet') }}
- </gl-new-button>
+ </gl-button>
</template>
</gl-modal>
</div>
diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js
index b826110117c..1c79492957d 100644
--- a/app/assets/javascripts/snippets/index.js
+++ b/app/assets/javascripts/snippets/index.js
@@ -3,7 +3,8 @@ import Translate from '~/vue_shared/translate';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
-import SnippetsApp from './components/show.vue';
+import SnippetsShow from './components/show.vue';
+import SnippetsEdit from './components/edit.vue';
Vue.use(VueApollo);
Vue.use(Translate);
@@ -31,7 +32,11 @@ function appFactory(el, Component) {
}
export const SnippetShowInit = () => {
- appFactory(document.getElementById('js-snippet-view'), SnippetsApp);
+ appFactory(document.getElementById('js-snippet-view'), SnippetsShow);
+};
+
+export const SnippetEditInit = () => {
+ appFactory(document.getElementById('js-snippet-edit'), SnippetsEdit);
};
export default () => {};
diff --git a/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
new file mode 100644
index 00000000000..f688868d1b9
--- /dev/null
+++ b/app/assets/javascripts/snippets/mutations/createSnippet.mutation.graphql
@@ -0,0 +1,8 @@
+mutation CreateSnippet($input: CreateSnippetInput!) {
+ createSnippet(input: $input) {
+ errors
+ snippet {
+ webUrl
+ }
+ }
+}
diff --git a/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
new file mode 100644
index 00000000000..548725f7357
--- /dev/null
+++ b/app/assets/javascripts/snippets/mutations/updateSnippet.mutation.graphql
@@ -0,0 +1,8 @@
+mutation UpdateSnippet($input: UpdateSnippetInput!) {
+ updateSnippet(input: $input) {
+ errors
+ snippet {
+ webUrl
+ }
+ }
+}
diff --git a/app/assets/javascripts/static_site_editor/components/invalid_content_message.vue b/app/assets/javascripts/static_site_editor/components/invalid_content_message.vue
new file mode 100644
index 00000000000..fef87057307
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/components/invalid_content_message.vue
@@ -0,0 +1,29 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlButton,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <h3>{{ s__('StaticSiteEditor|Incompatible file content') }}</h3>
+ <p>
+ {{
+ s__(
+ 'StaticSiteEditor|The Static Site Editor is currently configured to only edit Markdown content on pages generated from Middleman. Visit the documentation to learn more about configuring your site to use the Static Site Editor.',
+ )
+ }}
+ </p>
+ <div>
+ <gl-button
+ ref="documentationButton"
+ href="https://gitlab.com/gitlab-org/project-templates/static-site-editor-middleman"
+ >{{ s__('StaticSiteEditor|View documentation') }}</gl-button
+ >
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
index efb442d4d09..274d2f71749 100644
--- a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
+++ b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
@@ -1,9 +1,9 @@
<script>
-import { GlNewButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
- GlNewButton,
+ GlButton,
GlLoadingIcon,
},
props: {
@@ -29,16 +29,12 @@ export default {
<div class="d-flex bg-light border-top justify-content-between align-items-center py-3 px-4">
<gl-loading-icon :class="{ invisible: !savingChanges }" size="md" />
<div>
- <gl-new-button v-if="returnUrl" ref="returnUrlLink" :href="returnUrl">{{
+ <gl-button v-if="returnUrl" ref="returnUrlLink" :href="returnUrl">{{
s__('StaticSiteEditor|Return to site')
- }}</gl-new-button>
- <gl-new-button
- variant="success"
- :disabled="!saveable || savingChanges"
- @click="$emit('submit')"
- >
+ }}</gl-button>
+ <gl-button variant="success" :disabled="!saveable || savingChanges" @click="$emit('submit')">
{{ __('Submit Changes') }}
- </gl-new-button>
+ </gl-button>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue
index d76c6d9d681..41cb901720c 100644
--- a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue
+++ b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue
@@ -1,14 +1,14 @@
<script>
import { isString } from 'lodash';
-import { GlLink, GlNewButton } from '@gitlab/ui';
+import { GlLink, GlButton } from '@gitlab/ui';
const validateUrlAndLabel = value => isString(value.label) && isString(value.url);
export default {
components: {
GlLink,
- GlNewButton,
+ GlButton,
},
props: {
branch: {
@@ -46,16 +46,12 @@ export default {
}}
</p>
<div class="d-flex justify-content-end">
- <gl-new-button ref="returnToSiteButton" :href="returnUrl">{{
+ <gl-button ref="returnToSiteButton" :href="returnUrl">{{
s__('StaticSiteEditor|Return to site')
- }}</gl-new-button>
- <gl-new-button
- ref="mergeRequestButton"
- class="ml-2"
- :href="mergeRequest.url"
- variant="success"
- >{{ s__('StaticSiteEditor|View merge request') }}</gl-new-button
- >
+ }}</gl-button>
+ <gl-button ref="mergeRequestButton" class="ml-2" :href="mergeRequest.url" variant="success">
+ {{ s__('StaticSiteEditor|View merge request') }}
+ </gl-button>
</div>
</div>
@@ -64,7 +60,7 @@ export default {
<ul>
<li>
{{ s__('StaticSiteEditor|You created a new branch:') }}
- <gl-link ref="branchLink" :href="branch.url">{{ branch.label }}</gl-link>
+ <span ref="branchLink">{{ branch.label }}</span>
</li>
<li>
{{ s__('StaticSiteEditor|You created a merge request:') }}
diff --git a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue
index 4d912f5c0b5..82917319fc3 100644
--- a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue
+++ b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue
@@ -4,14 +4,20 @@ import { GlSkeletonLoader } from '@gitlab/ui';
import EditArea from './edit_area.vue';
import EditHeader from './edit_header.vue';
+import SavedChangesMessage from './saved_changes_message.vue';
import Toolbar from './publish_toolbar.vue';
+import InvalidContentMessage from './invalid_content_message.vue';
+import SubmitChangesError from './submit_changes_error.vue';
export default {
components: {
EditArea,
EditHeader,
+ InvalidContentMessage,
GlSkeletonLoader,
+ SavedChangesMessage,
Toolbar,
+ SubmitChangesError,
},
computed: {
...mapState([
@@ -19,44 +25,71 @@ export default {
'isLoadingContent',
'isSavingChanges',
'isContentLoaded',
+ 'isSupportedContent',
'returnUrl',
'title',
+ 'submitChangesError',
+ 'savedContentMeta',
]),
...mapGetters(['contentChanged']),
},
mounted() {
- this.loadContent();
+ if (this.isSupportedContent) {
+ this.loadContent();
+ }
},
methods: {
- ...mapActions(['loadContent', 'setContent', 'submitChanges']),
+ ...mapActions(['loadContent', 'setContent', 'submitChanges', 'dismissSubmitChangesError']),
},
};
</script>
<template>
<div class="d-flex justify-content-center h-100 pt-2">
- <div v-if="isLoadingContent" class="w-50 h-50">
- <gl-skeleton-loader :width="500" :height="102">
- <rect width="500" height="16" rx="4" />
- <rect y="20" width="375" height="16" rx="4" />
- <rect x="380" y="20" width="120" height="16" rx="4" />
- <rect y="40" width="250" height="16" rx="4" />
- <rect x="255" y="40" width="150" height="16" rx="4" />
- <rect x="410" y="40" width="90" height="16" rx="4" />
- </gl-skeleton-loader>
- </div>
- <div v-if="isContentLoaded" class="d-flex flex-grow-1 flex-column">
- <edit-header class="w-75 align-self-center py-2" :title="title" />
- <edit-area
- class="w-75 h-100 shadow-none align-self-center"
- :value="content"
- @input="setContent"
- />
- <toolbar
- :return-url="returnUrl"
- :saveable="contentChanged"
- :saving-changes="isSavingChanges"
- @submit="submitChanges"
- />
- </div>
+ <!-- Success view -->
+ <saved-changes-message
+ v-if="savedContentMeta"
+ :branch="savedContentMeta.branch"
+ :commit="savedContentMeta.commit"
+ :merge-request="savedContentMeta.mergeRequest"
+ :return-url="returnUrl"
+ />
+
+ <!-- Main view -->
+ <template v-else-if="isSupportedContent">
+ <div v-if="isLoadingContent" class="w-50 h-50">
+ <gl-skeleton-loader :width="500" :height="102">
+ <rect width="500" height="16" rx="4" />
+ <rect y="20" width="375" height="16" rx="4" />
+ <rect x="380" y="20" width="120" height="16" rx="4" />
+ <rect y="40" width="250" height="16" rx="4" />
+ <rect x="255" y="40" width="150" height="16" rx="4" />
+ <rect x="410" y="40" width="90" height="16" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+ <div v-if="isContentLoaded" class="d-flex flex-grow-1 flex-column">
+ <submit-changes-error
+ v-if="submitChangesError"
+ class="w-75 align-self-center"
+ :error="submitChangesError"
+ @retry="submitChanges"
+ @dismiss="dismissSubmitChangesError"
+ />
+ <edit-header class="w-75 align-self-center py-2" :title="title" />
+ <edit-area
+ class="w-75 h-100 shadow-none align-self-center"
+ :value="content"
+ @input="setContent"
+ />
+ <toolbar
+ :return-url="returnUrl"
+ :saveable="contentChanged"
+ :saving-changes="isSavingChanges"
+ @submit="submitChanges"
+ />
+ </div>
+ </template>
+
+ <!-- Error view -->
+ <invalid-content-message v-else class="w-75" />
</div>
</template>
diff --git a/app/assets/javascripts/static_site_editor/components/submit_changes_error.vue b/app/assets/javascripts/static_site_editor/components/submit_changes_error.vue
new file mode 100644
index 00000000000..c5b6c685124
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/components/submit_changes_error.vue
@@ -0,0 +1,24 @@
+<script>
+import { GlAlert, GlButton } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlAlert,
+ GlButton,
+ },
+ props: {
+ error: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <gl-alert variant="danger" dismissible @dismiss="$emit('dismiss')">
+ {{ s__('StaticSiteEditor|An error occurred while submitting your changes.') }} {{ error }}
+ <template #actions>
+ <gl-button variant="danger" @click="$emit('retry')">{{ __('Retry') }}</gl-button>
+ </template>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js
index c6a883c659a..15d668fd431 100644
--- a/app/assets/javascripts/static_site_editor/index.js
+++ b/app/assets/javascripts/static_site_editor/index.js
@@ -3,10 +3,17 @@ import StaticSiteEditor from './components/static_site_editor.vue';
import createStore from './store';
const initStaticSiteEditor = el => {
- const { projectId, returnUrl, path: sourcePath } = el.dataset;
+ const { projectId, path: sourcePath, returnUrl } = el.dataset;
+ const isSupportedContent = 'isSupportedContent' in el.dataset;
const store = createStore({
- initialState: { projectId, returnUrl, sourcePath, username: window.gon.current_username },
+ initialState: {
+ isSupportedContent,
+ projectId,
+ returnUrl,
+ sourcePath,
+ username: window.gon.current_username,
+ },
});
return new Vue({
diff --git a/app/assets/javascripts/static_site_editor/store/actions.js b/app/assets/javascripts/static_site_editor/store/actions.js
index c57ef86f6ef..9f5e9e8c589 100644
--- a/app/assets/javascripts/static_site_editor/store/actions.js
+++ b/app/assets/javascripts/static_site_editor/store/actions.js
@@ -26,9 +26,12 @@ export const submitChanges = ({ state: { projectId, content, sourcePath, usernam
return submitContentChanges({ content, projectId, sourcePath, username })
.then(data => commit(mutationTypes.SUBMIT_CHANGES_SUCCESS, data))
.catch(error => {
- commit(mutationTypes.SUBMIT_CHANGES_ERROR);
- createFlash(error.message);
+ commit(mutationTypes.SUBMIT_CHANGES_ERROR, error.message);
});
};
+export const dismissSubmitChangesError = ({ commit }) => {
+ commit(mutationTypes.DISMISS_SUBMIT_CHANGES_ERROR);
+};
+
export default () => {};
diff --git a/app/assets/javascripts/static_site_editor/store/mutation_types.js b/app/assets/javascripts/static_site_editor/store/mutation_types.js
index 35eb35ebbe9..9cf356aecc5 100644
--- a/app/assets/javascripts/static_site_editor/store/mutation_types.js
+++ b/app/assets/javascripts/static_site_editor/store/mutation_types.js
@@ -5,3 +5,4 @@ export const SET_CONTENT = 'setContent';
export const SUBMIT_CHANGES = 'submitChanges';
export const SUBMIT_CHANGES_SUCCESS = 'submitChangesSuccess';
export const SUBMIT_CHANGES_ERROR = 'submitChangesError';
+export const DISMISS_SUBMIT_CHANGES_ERROR = 'dismissSubmitChangesError';
diff --git a/app/assets/javascripts/static_site_editor/store/mutations.js b/app/assets/javascripts/static_site_editor/store/mutations.js
index 4727d04439c..72fe71f1c9b 100644
--- a/app/assets/javascripts/static_site_editor/store/mutations.js
+++ b/app/assets/javascripts/static_site_editor/store/mutations.js
@@ -19,13 +19,18 @@ export default {
},
[types.SUBMIT_CHANGES](state) {
state.isSavingChanges = true;
+ state.submitChangesError = '';
},
[types.SUBMIT_CHANGES_SUCCESS](state, meta) {
state.savedContentMeta = meta;
state.isSavingChanges = false;
state.originalContent = state.content;
},
- [types.SUBMIT_CHANGES_ERROR](state) {
+ [types.SUBMIT_CHANGES_ERROR](state, error) {
+ state.submitChangesError = error;
state.isSavingChanges = false;
},
+ [types.DISMISS_SUBMIT_CHANGES_ERROR](state) {
+ state.submitChangesError = '';
+ },
};
diff --git a/app/assets/javascripts/static_site_editor/store/state.js b/app/assets/javascripts/static_site_editor/store/state.js
index 98a84d9f75d..8c524b4ffe9 100644
--- a/app/assets/javascripts/static_site_editor/store/state.js
+++ b/app/assets/javascripts/static_site_editor/store/state.js
@@ -6,6 +6,7 @@ const createState = (initialState = {}) => ({
isLoadingContent: false,
isSavingChanges: false,
+ isSupportedContent: false,
isContentLoaded: false,
@@ -13,6 +14,7 @@ const createState = (initialState = {}) => ({
content: '',
title: '',
+ submitChangesError: '',
savedContentMeta: null,
...initialState,
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index f8c1c3634c2..bde00d72620 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -38,8 +38,7 @@ const populateUserInfo = user => {
name: userData.name,
location: userData.location,
bio: userData.bio,
- organization: userData.organization,
- jobTitle: userData.job_title,
+ workInformation: userData.work_information,
loaded: true,
});
}
@@ -71,7 +70,7 @@ export default (elements = document.querySelectorAll('.js-user-link')) => {
const user = {
location: null,
bio: null,
- organization: null,
+ workInformation: null,
status: null,
loaded: false,
};
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
new file mode 100644
index 00000000000..848295cc984
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -0,0 +1,178 @@
+<script>
+import { groupBy } from 'lodash';
+import { GlIcon } from '@gitlab/ui';
+import tooltip from '~/vue_shared/directives/tooltip';
+import { glEmojiTag } from '../../emoji';
+import { __, sprintf } from '~/locale';
+
+// Internal constant, specific to this component, used when no `currentUserId` is given
+const NO_USER_ID = -1;
+
+export default {
+ components: {
+ GlIcon,
+ },
+ directives: {
+ tooltip,
+ },
+ props: {
+ awards: {
+ type: Array,
+ required: true,
+ },
+ canAwardEmoji: {
+ type: Boolean,
+ required: true,
+ },
+ currentUserId: {
+ type: Number,
+ required: false,
+ default: NO_USER_ID,
+ },
+ addButtonClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ groupedAwards() {
+ const { thumbsup, thumbsdown, ...rest } = groupBy(this.awards, x => x.name);
+
+ return [
+ ...(thumbsup ? [this.createAwardList('thumbsup', thumbsup)] : []),
+ ...(thumbsdown ? [this.createAwardList('thumbsdown', thumbsdown)] : []),
+ ...Object.entries(rest).map(([name, list]) => this.createAwardList(name, list)),
+ ];
+ },
+ isAuthoredByMe() {
+ return this.noteAuthorId === this.currentUserId;
+ },
+ },
+ methods: {
+ getAwardClassBindings(awardList) {
+ return {
+ active: this.hasReactionByCurrentUser(awardList),
+ disabled: this.currentUserId === NO_USER_ID,
+ };
+ },
+ hasReactionByCurrentUser(awardList) {
+ if (this.currentUserId === NO_USER_ID) {
+ return false;
+ }
+
+ return awardList.some(award => award.user.id === this.currentUserId);
+ },
+ createAwardList(name, list) {
+ return {
+ name,
+ list,
+ title: this.getAwardListTitle(list),
+ classes: this.getAwardClassBindings(list),
+ html: glEmojiTag(name),
+ };
+ },
+ getAwardListTitle(awardsList) {
+ const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList);
+ const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
+ let awardList = awardsList;
+
+ // Filter myself from list if I am awarded.
+ if (hasReactionByCurrentUser) {
+ awardList = awardList.filter(award => award.user.id !== this.currentUserId);
+ }
+
+ // Get only 9-10 usernames to show in tooltip text.
+ const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name);
+
+ // Get the remaining list to use in `and x more` text.
+ const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length);
+
+ // Add myself to the beginning of the list so title will start with You.
+ if (hasReactionByCurrentUser) {
+ namesToShow.unshift(__('You'));
+ }
+
+ let title = '';
+
+ // We have 10+ awarded user, join them with comma and add `and x more`.
+ if (remainingAwardList.length) {
+ title = sprintf(
+ __(`%{listToShow}, and %{awardsListLength} more.`),
+ {
+ listToShow: namesToShow.join(', '),
+ awardsListLength: remainingAwardList.length,
+ },
+ false,
+ );
+ } else if (namesToShow.length > 1) {
+ // Join all names with comma but not the last one, it will be added with and text.
+ title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
+ // If we have more than 2 users we need an extra comma before and text.
+ title += namesToShow.length > 2 ? ',' : '';
+ title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }, false); // Append and text
+ } else {
+ // We have only 2 users so join them with and.
+ title = namesToShow.join(__(' and '));
+ }
+
+ return title;
+ },
+ handleAward(awardName) {
+ if (!this.canAwardEmoji) {
+ return;
+ }
+
+ // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string
+ const parsedName = /^[0-9]+$/.test(awardName) ? Number(awardName) : awardName;
+
+ this.$emit('award', parsedName);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="awards js-awards-block">
+ <button
+ v-for="awardList in groupedAwards"
+ :key="awardList.name"
+ v-tooltip
+ :class="awardList.classes"
+ :title="awardList.title"
+ data-boundary="viewport"
+ data-testid="award-button"
+ class="btn award-control"
+ type="button"
+ @click="handleAward(awardList.name)"
+ >
+ <span data-testid="award-html" v-html="awardList.html"></span>
+ <span class="award-control-text js-counter">{{ awardList.list.length }}</span>
+ </button>
+ <div v-if="canAwardEmoji" class="award-menu-holder">
+ <button
+ v-tooltip
+ :class="addButtonClass"
+ class="award-control btn js-add-award"
+ title="Add reaction"
+ :aria-label="__('Add reaction')"
+ data-boundary="viewport"
+ type="button"
+ >
+ <span class="award-control-icon award-control-icon-neutral">
+ <gl-icon aria-hidden="true" name="slight-smile" />
+ </span>
+ <span class="award-control-icon award-control-icon-positive">
+ <gl-icon aria-hidden="true" name="smiley" />
+ </span>
+ <span class="award-control-icon award-control-icon-super-positive">
+ <gl-icon aria-hidden="true" name="smiley" />
+ </span>
+ <i
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"
+ ></i>
+ </button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
index 3b9b9f37f52..7826c179889 100644
--- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
@@ -3,7 +3,7 @@ import {
GlNewDropdown,
GlNewDropdownHeader,
GlFormInputGroup,
- GlNewButton,
+ GlButton,
GlIcon,
GlTooltipDirective,
} from '@gitlab/ui';
@@ -15,7 +15,7 @@ export default {
GlNewDropdown,
GlNewDropdownHeader,
GlFormInputGroup,
- GlNewButton,
+ GlButton,
GlIcon,
},
directives: {
@@ -55,13 +55,13 @@ export default {
<div class="mx-3">
<gl-form-input-group :value="sshLink" readonly select-on-click>
<template #append>
- <gl-new-button
+ <gl-button
v-gl-tooltip.hover
:title="$options.copyURLTooltip"
:data-clipboard-text="sshLink"
>
<gl-icon name="copy-to-clipboard" :title="$options.copyURLTooltip" />
- </gl-new-button>
+ </gl-button>
</template>
</gl-form-input-group>
</div>
@@ -73,13 +73,13 @@ export default {
<div class="mx-3">
<gl-form-input-group :value="httpLink" readonly select-on-click>
<template #append>
- <gl-new-button
+ <gl-button
v-gl-tooltip.hover
:title="$options.copyURLTooltip"
:data-clipboard-text="httpLink"
>
<gl-icon name="copy-to-clipboard" :title="$options.copyURLTooltip" />
- </gl-new-button>
+ </gl-button>
</template>
</gl-form-input-group>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
index cdcd5cdef7f..ffc616d7309 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
@@ -158,7 +158,7 @@ export default {
<template>
<tooltip-on-truncate
:title="timeWindowText"
- :truncate-target="elem => elem.querySelector('.date-time-picker-toggle')"
+ :truncate-target="elem => elem.querySelector('.gl-dropdown-toggle-text')"
placement="top"
class="d-inline-block"
>
diff --git a/app/assets/javascripts/vue_shared/components/form/title.vue b/app/assets/javascripts/vue_shared/components/form/title.vue
index f8f70529bd1..fad69dc1e24 100644
--- a/app/assets/javascripts/vue_shared/components/form/title.vue
+++ b/app/assets/javascripts/vue_shared/components/form/title.vue
@@ -10,6 +10,6 @@ export default {
</script>
<template>
<gl-form-group :label="__('Title')" label-for="title-field-edit">
- <gl-form-input id="title-field-edit" v-bind="$attrs" v-on="$listeners" />
+ <gl-form-input v-bind="$attrs" v-on="$listeners" />
</gl-form-group>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
index 913c971a512..040a15406e0 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
@@ -37,7 +37,7 @@ export default {
:title="tooltipLabel"
:class="cssClasses"
type="button"
- class="btn btn-blank gutter-toggle btn-sidebar-action"
+ class="btn btn-blank gutter-toggle btn-sidebar-action js-sidebar-vue-toggle"
data-container="body"
data-placement="left"
data-boundary="viewport"
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index 602d4ab89e1..595baeeb14f 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -1,10 +1,8 @@
<script>
-import { GlPopover, GlSkeletonLoading, GlSprintf } from '@gitlab/ui';
+import { GlPopover, GlSkeletonLoading } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../../emoji';
-import { s__ } from '~/locale';
-import { isString } from 'lodash';
export default {
name: 'UserPopover',
@@ -12,7 +10,6 @@ export default {
Icon,
GlPopover,
GlSkeletonLoading,
- GlSprintf,
UserAvatarImage,
},
props: {
@@ -49,26 +46,7 @@ export default {
return !this.user.name;
},
workInformationIsLoading() {
- return !this.user.loaded && this.workInformation === null;
- },
- workInformation() {
- const { jobTitle, organization } = this.user;
-
- if (organization && jobTitle) {
- return {
- message: s__('Profile|%{job_title} at %{organization}'),
- placeholders: { job_title: jobTitle, organization },
- };
- } else if (organization) {
- return organization;
- } else if (jobTitle) {
- return jobTitle;
- }
-
- return null;
- },
- workInformationShouldUseSprintf() {
- return !isString(this.workInformation);
+ return !this.user.loaded && this.user.workInformation === null;
},
locationIsLoading() {
return !this.user.loaded && this.user.location === null;
@@ -98,23 +76,13 @@ export default {
<icon name="profile" class="category-icon flex-shrink-0" />
<span ref="bio" class="ml-1">{{ user.bio }}</span>
</div>
- <div v-if="workInformation" class="d-flex mb-1">
+ <div v-if="user.workInformation" class="d-flex mb-1">
<icon
v-show="!workInformationIsLoading"
name="work"
class="category-icon flex-shrink-0"
/>
- <span ref="workInformation" class="ml-1">
- <gl-sprintf v-if="workInformationShouldUseSprintf" :message="workInformation.message">
- <template
- v-for="(placeholder, slotName) in workInformation.placeholders"
- v-slot:[slotName]
- >
- <span :key="slotName">{{ placeholder }}</span>
- </template>
- </gl-sprintf>
- <span v-else>{{ workInformation }}</span>
- </span>
+ <span ref="workInformation" class="ml-1">{{ user.workInformation }}</span>
</div>
<gl-skeleton-loading
v-if="workInformationIsLoading"
diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss
index 6820bdca2fa..ce1039832d3 100644
--- a/app/assets/stylesheets/components/related_items_list.scss
+++ b/app/assets/stylesheets/components/related_items_list.scss
@@ -73,6 +73,11 @@ $item-weight-max-width: 48px;
.issue-token-state-icon-closed {
display: none;
}
+
+ .sortable-link {
+ color: $gray-900;
+ font-weight: normal;
+ }
}
.item-path-id .path-id-text,
@@ -249,6 +254,12 @@ $item-weight-max-width: 48px;
line-height: 0;
}
+@include media-breakpoint-down(xs) {
+ .btn-sm.dropdown-toggle-split {
+ max-width: 40px;
+ }
+}
+
@include media-breakpoint-up(sm) {
.item-info-area {
flex-basis: 100%;
@@ -296,10 +307,6 @@ $item-weight-max-width: 48px;
.item-meta {
.item-meta-child {
flex-basis: unset;
-
- ~ .item-assignees {
- margin-left: $gl-padding-4;
- }
}
}
@@ -353,7 +360,7 @@ $item-weight-max-width: 48px;
}
.item-title-wrapper {
- max-width: calc(100% - 440px);
+ max-width: calc(100% - 500px);
}
.item-info-area {
@@ -407,7 +414,7 @@ $item-weight-max-width: 48px;
}
}
-@media only screen and (min-width: 1400px) {
+@media only screen and (min-width: 1500px) {
.card-header,
.item-body {
.health-label-short {
@@ -419,7 +426,9 @@ $item-weight-max-width: 48px;
}
}
- .item-body .item-title-wrapper {
- max-width: calc(100% - 570px);
+ .item-body {
+ .item-title-wrapper {
+ max-width: calc(100% - 640px);
+ }
}
}
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index b1d79a41ba7..0292919ea50 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -105,10 +105,6 @@
}
}
- .js-ca-dropdown {
- top: $gl-padding-top;
- }
-
.stage-panel-body {
display: flex;
flex-wrap: wrap;
diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss
index af0afa9cc3b..f61245bed24 100644
--- a/app/assets/stylesheets/pages/prometheus.scss
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -64,6 +64,12 @@
padding: $gl-padding-8 $gl-padding-12;
}
}
+
+ .show-last-dropdown {
+ // same as in .dropdown-menu-toggle
+ // see app/assets/stylesheets/framework/dropdowns.scss
+ width: 160px;
+ }
}
.prometheus-panel {
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 2a811e08fd3..b829a7b518e 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -87,6 +87,11 @@
.gl-bg-orange-100 { @include gl-bg-orange-100; }
.gl-bg-gray-100 { @include gl-bg-gray-100; }
.gl-bg-green-100 { @include gl-bg-green-100;}
+.gl-bg-blue-500 { @include gl-bg-blue-500; }
+.gl-bg-green-500 { @include gl-bg-green-500; }
+.gl-bg-theme-indigo-500 { @include gl-bg-theme-indigo-500; }
+.gl-bg-red-500 { @include gl-bg-red-500; }
+.gl-bg-orange-500 { @include gl-bg-orange-500; }
.gl-text-blue-500 { @include gl-text-blue-500; }
.gl-text-gray-500 { @include gl-text-gray-500; }
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 9eaa55039c8..4639d8adfe0 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -61,7 +61,15 @@ class Admin::RunnersController < Admin::ApplicationController
end
def runner_params
- params.require(:runner).permit(Ci::Runner::FORM_EDITABLE)
+ params.require(:runner).permit(permitted_attrs)
+ end
+
+ def permitted_attrs
+ if Gitlab.com?
+ Ci::Runner::FORM_EDITABLE + Ci::Runner::MINUTES_COST_FACTOR_FIELDS
+ else
+ Ci::Runner::FORM_EDITABLE
+ end
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index b2496427924..26ef6117e1c 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -150,7 +150,7 @@ class ApplicationController < ActionController::Base
payload[:username] = logged_user.try(:username)
end
- payload[:queue_duration] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY]
+ payload[:queue_duration_s] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY]
end
##
diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb
index 3ebd248c29e..de14bd319e0 100644
--- a/app/controllers/clusters/applications_controller.rb
+++ b/app/controllers/clusters/applications_controller.rb
@@ -47,7 +47,7 @@ class Clusters::ApplicationsController < Clusters::BaseController
end
def cluster_application_params
- params.permit(:application, :hostname, :pages_domain_id, :email, :stack, :modsecurity_enabled, :modsecurity_mode)
+ params.permit(:application, :hostname, :pages_domain_id, :email, :stack, :modsecurity_enabled, :modsecurity_mode, :host, :port, :protocol)
end
def cluster_application_destroy_params
diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb
index 825181568ad..d486d734db8 100644
--- a/app/controllers/concerns/enforces_two_factor_authentication.rb
+++ b/app/controllers/concerns/enforces_two_factor_authentication.rb
@@ -16,7 +16,7 @@ module EnforcesTwoFactorAuthentication
end
def check_two_factor_requirement
- if two_factor_authentication_required? && current_user && !current_user.temp_oauth_email? && !current_user.two_factor_enabled? && !skip_two_factor?
+ if two_factor_authentication_required? && current_user_requires_two_factor?
redirect_to profile_two_factor_auth_path
end
end
@@ -27,6 +27,10 @@ module EnforcesTwoFactorAuthentication
current_user.try(:ultraauth_user?)
end
+ def current_user_requires_two_factor?
+ current_user && !current_user.temp_oauth_email? && !current_user.two_factor_enabled? && !skip_two_factor?
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def two_factor_authentication_reason(global: -> {}, group: -> {})
if two_factor_authentication_required?
@@ -61,3 +65,5 @@ module EnforcesTwoFactorAuthentication
session[:skip_two_factor] && session[:skip_two_factor] > Time.current
end
end
+
+EnforcesTwoFactorAuthentication.prepend_if_ee('EE::EnforcesTwoFactorAuthentication')
diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb
index 4c998055a5d..ff283f9bb62 100644
--- a/app/controllers/concerns/integrations_actions.rb
+++ b/app/controllers/concerns/integrations_actions.rb
@@ -15,9 +15,7 @@ module IntegrationsActions
end
def update
- integration.attributes = service_params[:service]
-
- saved = integration.save(context: :manual_change)
+ saved = integration.update(service_params[:service])
respond_to do |format|
format.html do
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index 039991e07a2..c173d7d2310 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -61,22 +61,24 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
end
end
- # rubocop: disable CodeReuse/ActiveRecord
def load_projects(finder_params)
@total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute
@total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute
finder_params[:use_cte] = true if use_cte_for_finder?
- projects = ProjectsFinder
- .new(params: finder_params, current_user: current_user)
- .execute
- .includes(:route, :creator, :group, namespace: [:route, :owner])
- .preload(:project_feature)
- .page(finder_params[:page])
+ projects = ProjectsFinder.new(params: finder_params, current_user: current_user).execute
+
+ projects = preload_associations(projects)
+ projects = projects.page(finder_params[:page])
prepare_projects_for_rendering(projects)
end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def preload_associations(projects)
+ projects.includes(:route, :creator, :group, namespace: [:route, :owner]).preload(:project_feature)
+ end
# rubocop: enable CodeReuse/ActiveRecord
def use_cte_for_finder?
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index a8a76b47bbe..705a586d614 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -66,18 +66,21 @@ class Explore::ProjectsController < Explore::ApplicationController
@total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute
end
- # rubocop: disable CodeReuse/ActiveRecord
def load_projects
load_project_counts
- projects = ProjectsFinder.new(current_user: current_user, params: params)
- .execute
- .includes(:route, :creator, :group, namespace: [:route, :owner])
- .page(params[:page])
- .without_count
+ projects = ProjectsFinder.new(current_user: current_user, params: params).execute
+
+ projects = preload_associations(projects)
+ projects = projects.page(params[:page]).without_count
prepare_projects_for_rendering(projects)
end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def preload_associations(projects)
+ projects.includes(:route, :creator, :group, namespace: [:route, :owner])
+ end
# rubocop: enable CodeReuse/ActiveRecord
def set_sorting
@@ -110,3 +113,5 @@ class Explore::ProjectsController < Explore::ApplicationController
end
end
end
+
+Explore::ProjectsController.prepend_if_ee('EE::Explore::ProjectsController')
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index a478e9fffb8..8cfbd293597 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -5,6 +5,9 @@ class Groups::MilestonesController < Groups::ApplicationController
before_action :milestone, only: [:edit, :show, :update, :merge_requests, :participants, :labels, :destroy]
before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update, :destroy]
+ before_action do
+ push_frontend_feature_flag(:burnup_charts)
+ end
def index
respond_to do |format|
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index 6b842fc9fe1..bfe7987176a 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -7,7 +7,7 @@ module Groups
before_action :authorize_admin_group!
before_action :authorize_update_max_artifacts_size!, only: [:update]
before_action do
- push_frontend_feature_flag(:new_variables_ui, @group)
+ push_frontend_feature_flag(:new_variables_ui, @group, default_enabled: true)
push_frontend_feature_flag(:ajax_new_deploy_token, @group)
end
before_action :define_variables, only: [:show, :create_deploy_token]
@@ -43,7 +43,7 @@ module Groups
end
def create_deploy_token
- result = Projects::DeployTokens::CreateService.new(@group, current_user, deploy_token_params).execute
+ result = Groups::DeployTokens::CreateService.new(@group, current_user, deploy_token_params).execute
@new_deploy_token = result[:deploy_token]
if result[:status] == :success
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index 248b75d16ed..ebc81976529 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -13,16 +13,13 @@ class Projects::ForksController < Projects::ApplicationController
before_action :authorize_fork_project!, only: [:new, :create]
before_action :authorize_fork_namespace!, only: [:create]
- # rubocop: disable CodeReuse/ActiveRecord
def index
@total_forks_count = project.forks.size
@public_forks_count = project.forks.public_only.size
@private_forks_count = @total_forks_count - project.forks.public_and_internal_only.size
@internal_forks_count = @total_forks_count - @public_forks_count - @private_forks_count
- @forks = ForkProjectsFinder.new(project, params: params.merge(search: params[:filter_projects]), current_user: current_user).execute
- @forks = @forks.includes(:route, :creator, :group, namespace: [:route, :owner])
- .page(params[:page])
+ @forks = load_forks.page(params[:page])
prepare_projects_for_rendering(@forks)
@@ -36,7 +33,6 @@ class Projects::ForksController < Projects::ApplicationController
end
end
end
- # rubocop: enable CodeReuse/ActiveRecord
def new
@namespaces = fork_service.valid_fork_targets - [project.namespace]
@@ -59,10 +55,19 @@ class Projects::ForksController < Projects::ApplicationController
redirect_to project_path(@forked_project), notice: "The project '#{@forked_project.name}' was successfully forked."
end
end
- # rubocop: enable CodeReuse/ActiveRecord
private
+ def load_forks
+ forks = ForkProjectsFinder.new(
+ project,
+ params: params.merge(search: params[:filter_projects]),
+ current_user: current_user
+ ).execute
+
+ forks.includes(:route, :creator, :group, namespace: [:route, :owner])
+ end
+
def fork_service
strong_memoize(:fork_service) do
::Projects::ForkService.new(project, current_user, namespace: fork_namespace)
@@ -83,3 +88,5 @@ class Projects::ForksController < Projects::ApplicationController
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42335')
end
end
+
+Projects::ForksController.prepend_if_ee('EE::Projects::ForksController')
diff --git a/app/controllers/projects/import/jira_controller.rb b/app/controllers/projects/import/jira_controller.rb
index 26d9b4b223f..4a70ed45404 100644
--- a/app/controllers/projects/import/jira_controller.rb
+++ b/app/controllers/projects/import/jira_controller.rb
@@ -11,11 +11,10 @@ module Projects
before_action :authorize_admin_project!, only: [:import]
def show
- @is_jira_configured = @project.jira_service.present?
- return if Feature.enabled?(:jira_issue_import_vue, @project)
+ jira_service = @project.jira_service
- if !@project.latest_jira_import&.in_progress? && current_user&.can?(:admin_project, @project)
- jira_client = @project.jira_service.client
+ if jira_service.present? && !@project.latest_jira_import&.in_progress? && current_user&.can?(:admin_project, @project)
+ jira_client = jira_service.client
jira_projects = jira_client.Project.all
if jira_projects.present?
@@ -25,7 +24,9 @@ module Projects
end
end
- flash[:notice] = _("Import %{status}") % { status: @project.jira_import_status } unless @project.latest_jira_import&.initial?
+ unless Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true)
+ flash[:notice] = _("Import %{status}") % { status: @project.jira_import_status } unless @project.latest_jira_import&.initial?
+ end
end
def import
@@ -50,7 +51,7 @@ module Projects
end
def jira_integration_configured?
- return if Feature.enabled?(:jira_issue_import_vue, @project)
+ return if Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true)
return if @project.jira_service
flash[:notice] = _("Configure the Jira integration first on your project's %{strong_start} Settings > Integrations > Jira%{strong_end} page." %
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 51ad8edb012..3aae8990f07 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -11,7 +11,7 @@ class Projects::IssuesController < Projects::ApplicationController
include RecordUserLastActivity
def issue_except_actions
- %i[index calendar new create bulk_update import_csv]
+ %i[index calendar new create bulk_update import_csv export_csv]
end
def set_issuables_index_only_actions
@@ -20,7 +20,7 @@ class Projects::IssuesController < Projects::ApplicationController
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) }
- prepend_before_action :authenticate_user!, only: [:new]
+ prepend_before_action :authenticate_user!, only: [:new, :export_csv]
# designs is only applicable to EE, but defining a prepend_before_action in EE code would overwrite this
prepend_before_action :store_uri, only: [:new, :show, :designs]
@@ -189,6 +189,13 @@ class Projects::IssuesController < Projects::ApplicationController
end
end
+ def export_csv
+ ExportCsvWorker.perform_async(current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker
+
+ index_path = project_issues_path(project)
+ redirect_to(index_path, notice: "Your CSV export has started. It will be emailed to #{current_user.notification_email} when complete.")
+ end
+
def import_csv
if uploader = UploadService.new(project, params[:file]).execute
ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id) # rubocop:disable CodeReuse/Worker
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 89de40006ff..cbab68b2827 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -25,6 +25,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline)
push_frontend_feature_flag(:code_navigation, @project)
push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true)
+ push_frontend_feature_flag(:merge_ref_head_comments, @project)
end
before_action do
@@ -339,11 +340,13 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def serialize_widget(merge_request)
- serializer.represent(merge_request, serializer: 'widget')
+ cached_data = serializer.represent(merge_request, serializer: 'poll_cached_widget')
+ widget_data = serializer.represent(merge_request, serializer: 'poll_widget')
+ cached_data.merge!(widget_data)
end
def serializer
- MergeRequestSerializer.new(current_user: current_user, project: merge_request.project)
+ @serializer ||= MergeRequestSerializer.new(current_user: current_user, project: merge_request.project)
end
def define_edit_vars
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index d301a5be391..56f1f1a1019 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -6,6 +6,9 @@ class Projects::MilestonesController < Projects::ApplicationController
before_action :check_issuables_available!
before_action :milestone, only: [:edit, :update, :destroy, :show, :merge_requests, :participants, :labels, :promote]
+ before_action do
+ push_frontend_feature_flag(:burnup_charts)
+ end
# Allow read any milestone
before_action :authorize_read_milestone!
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index a0f98d8f1d2..c7cd9649dac 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -6,8 +6,9 @@ module Projects
before_action :authorize_admin_pipeline!
before_action :define_variables
before_action do
- push_frontend_feature_flag(:new_variables_ui, @project)
+ push_frontend_feature_flag(:new_variables_ui, @project, default_enabled: true)
push_frontend_feature_flag(:ajax_new_deploy_token, @project)
+ push_frontend_feature_flag(:ci_key_autocomplete, default_enabled: true)
end
def show
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 045aa38230c..bb20ea1de49 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -36,6 +36,10 @@ class ProjectsController < Projects::ApplicationController
layout :determine_layout
+ before_action do
+ push_frontend_feature_flag(:metrics_dashboard_visibility_switching_available)
+ end
+
def index
redirect_to(current_user ? root_path : explore_root_path)
end
diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb
index 9e134ba9526..118036de230 100644
--- a/app/controllers/repositories/git_http_controller.rb
+++ b/app/controllers/repositories/git_http_controller.rb
@@ -23,7 +23,7 @@ module Repositories
# POST /foo/bar.git/git-upload-pack (git pull)
def git_upload_pack
- enqueue_fetch_statistics_update
+ update_fetch_statistics
render_ok
end
@@ -76,12 +76,16 @@ module Repositories
render plain: exception.message, status: :service_unavailable
end
- def enqueue_fetch_statistics_update
+ def update_fetch_statistics
+ return unless project
return if Gitlab::Database.read_only?
return unless repo_type.project?
- return unless project&.daily_statistics_enabled?
- ProjectDailyStatisticsWorker.perform_async(project.id) # rubocop:disable CodeReuse/Worker
+ if Feature.enabled?(:project_statistics_sync, project, default_enabled: true)
+ Projects::FetchStatisticsIncrementService.new(project).execute
+ else
+ ProjectDailyStatisticsWorker.perform_async(project.id) # rubocop:disable CodeReuse/Worker
+ end
end
def access
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 06374736dcf..5ee97885071 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -128,6 +128,10 @@ class UsersController < ApplicationController
@user ||= find_routable!(User, params[:username])
end
+ def personal_projects
+ PersonalProjectsFinder.new(user).execute(current_user)
+ end
+
def contributed_projects
ContributedProjectsFinder.new(user).execute(current_user)
end
@@ -147,8 +151,7 @@ class UsersController < ApplicationController
end
def load_projects
- @projects =
- PersonalProjectsFinder.new(user).execute(current_user)
+ @projects = personal_projects
.page(params[:page])
.per(params[:limit])
diff --git a/app/finders/autocomplete/move_to_project_finder.rb b/app/finders/autocomplete/move_to_project_finder.rb
index af6defc1fc6..f1c1eacafe6 100644
--- a/app/finders/autocomplete/move_to_project_finder.rb
+++ b/app/finders/autocomplete/move_to_project_finder.rb
@@ -28,7 +28,8 @@ module Autocomplete
.optionally_search(search, include_namespace: true)
.excluding_project(project_id)
.eager_load_namespace_and_owner
- .sorted_by_name_asc_limited(LIMIT)
+ .sorted_by_stars_desc
+ .limit(LIMIT) # rubocop: disable CodeReuse/ActiveRecord
end
end
end
diff --git a/app/finders/autocomplete/routes_finder.rb b/app/finders/autocomplete/routes_finder.rb
new file mode 100644
index 00000000000..b3f2693b273
--- /dev/null
+++ b/app/finders/autocomplete/routes_finder.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Autocomplete
+ # Finder that returns a list of routes that match on the `path` attribute.
+ class RoutesFinder
+ attr_reader :current_user, :search
+
+ LIMIT = 20
+
+ def initialize(current_user, params = {})
+ @current_user = current_user
+ @search = params[:search]
+ end
+
+ def execute
+ return [] if @search.blank?
+
+ Route
+ .for_routable(routables)
+ .sort_by_path_length
+ .fuzzy_search(@search, [:path])
+ .limit(LIMIT) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ private
+
+ def routables
+ raise NotImplementedError
+ end
+
+ class NamespacesOnly < self
+ def routables
+ return Namespace.all if current_user.admin?
+
+ current_user.namespaces
+ end
+ end
+
+ class ProjectsOnly < self
+ def routables
+ return Project.all if current_user.admin?
+
+ current_user.projects
+ end
+ end
+ end
+end
diff --git a/app/finders/metrics/dashboards/annotations_finder.rb b/app/finders/metrics/dashboards/annotations_finder.rb
new file mode 100644
index 00000000000..c42b8bf40e5
--- /dev/null
+++ b/app/finders/metrics/dashboards/annotations_finder.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Metrics
+ module Dashboards
+ class AnnotationsFinder
+ def initialize(dashboard:, params:)
+ @dashboard, @params = dashboard, params
+ end
+
+ def execute
+ if dashboard.environment
+ apply_filters_to(annotations_for_environment)
+ else
+ Metrics::Dashboard::Annotation.none
+ end
+ end
+
+ private
+
+ attr_reader :dashboard, :params
+
+ def apply_filters_to(annotations)
+ annotations = annotations.after(params[:from]) if params[:from].present?
+ annotations = annotations.before(params[:to]) if params[:to].present? && valid_timespan_boundaries?
+
+ by_dashboard(annotations)
+ end
+
+ def annotations_for_environment
+ dashboard.environment.metrics_dashboard_annotations
+ end
+
+ def by_dashboard(annotations)
+ annotations.for_dashboard(dashboard.path)
+ end
+
+ def valid_timespan_boundaries?
+ params[:from].blank? || params[:to] >= params[:from]
+ end
+ end
+ end
+end
diff --git a/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb
new file mode 100644
index 00000000000..068323a3073
--- /dev/null
+++ b/app/graphql/resolvers/metrics/dashboards/annotation_resolver.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Metrics
+ module Dashboards
+ class AnnotationResolver < Resolvers::BaseResolver
+ argument :from, Types::TimeType,
+ required: true,
+ description: "Timestamp marking date and time from which annotations need to be fetched"
+
+ argument :to, Types::TimeType,
+ required: false,
+ description: "Timestamp marking date and time to which annotations need to be fetched"
+
+ type Types::Metrics::Dashboards::AnnotationType, null: true
+
+ alias_method :dashboard, :object
+
+ def resolve(**args)
+ return [] unless dashboard
+ return [] unless Feature.enabled?(:metrics_dashboard_annotations, dashboard.environment&.project)
+
+ ::Metrics::Dashboards::AnnotationsFinder.new(dashboard: dashboard, params: args).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 8f6b742a93c..cd4c6b4d46a 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -60,7 +60,7 @@ module Types
description: 'Indicates if the source branch of the merge request will be deleted after merge'
field :force_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :force_remove_source_branch?, null: true,
description: 'Indicates if the project settings will lead to source branch deletion after merge'
- field :merge_status, GraphQL::STRING_TYPE, null: true,
+ field :merge_status, GraphQL::STRING_TYPE, method: :public_merge_status, null: true,
description: 'Status of the merge request'
field :in_progress_merge_commit_sha, GraphQL::STRING_TYPE, null: true,
description: 'Commit SHA of the merge request if merge is in progress'
diff --git a/app/graphql/types/metrics/dashboard_type.rb b/app/graphql/types/metrics/dashboard_type.rb
index 11e834013ca..e7d09866bb5 100644
--- a/app/graphql/types/metrics/dashboard_type.rb
+++ b/app/graphql/types/metrics/dashboard_type.rb
@@ -9,6 +9,11 @@ module Types
field :path, GraphQL::STRING_TYPE, null: true,
description: 'Path to a file with the dashboard definition'
+
+ field :annotations, Types::Metrics::Dashboards::AnnotationType.connection_type, null: true,
+ description: 'Annotations added to the dashboard. Will always return `null` ' \
+ 'if `metrics_dashboard_annotations` feature flag is disabled',
+ resolver: Resolvers::Metrics::Dashboards::AnnotationResolver
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/metrics/dashboards/annotation_type.rb b/app/graphql/types/metrics/dashboards/annotation_type.rb
new file mode 100644
index 00000000000..055d2544eff
--- /dev/null
+++ b/app/graphql/types/metrics/dashboards/annotation_type.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Types
+ module Metrics
+ module Dashboards
+ class AnnotationType < ::Types::BaseObject
+ authorize :read_metrics_dashboard_annotation
+ graphql_name 'MetricsDashboardAnnotation'
+
+ field :description, GraphQL::STRING_TYPE, null: true,
+ description: 'Description of the annotation'
+
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the annotation'
+
+ field :panel_id, GraphQL::STRING_TYPE, null: true,
+ description: 'ID of a dashboard panel to which the annotation should be scoped'
+
+ field :starting_at, GraphQL::STRING_TYPE, null: true,
+ description: 'Timestamp marking start of annotated time span'
+
+ field :ending_at, GraphQL::STRING_TYPE, null: true,
+ description: 'Timestamp marking end of annotated time span'
+
+ def panel_id
+ object.panel_xid
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 3115a53e053..8356e763be9 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -26,7 +26,7 @@ module Types
markdown_field :description_html, null: true
field :tag_list, GraphQL::STRING_TYPE, null: true,
- description: 'List of project tags'
+ description: 'List of project topics (not Git tags)'
field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true,
description: 'URL to connect to the project via SSH'
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 8a79217c929..070089d6ef8 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -9,19 +9,6 @@ module PreferencesHelper
]
end
- # Maps `dashboard` values to more user-friendly option text
- DASHBOARD_CHOICES = {
- projects: _("Your Projects (default)"),
- stars: _("Starred Projects"),
- project_activity: _("Your Projects' Activity"),
- starred_project_activity: _("Starred Projects' Activity"),
- groups: _("Your Groups"),
- todos: _("Your To-Do List"),
- issues: _("Assigned Issues"),
- merge_requests: _("Assigned Merge Requests"),
- operations: _("Operations Dashboard")
- }.with_indifferent_access.freeze
-
# Returns an Array usable by a select field for more user-friendly option text
def dashboard_choices
dashboards = User.dashboards.keys
@@ -31,10 +18,25 @@ module PreferencesHelper
dashboards.map do |key|
# Use `fetch` so `KeyError` gets raised when a key is missing
- [DASHBOARD_CHOICES.fetch(key), key]
+ [localized_dashboard_choices.fetch(key), key]
end
end
+ # Maps `dashboard` values to more user-friendly option text
+ def localized_dashboard_choices
+ {
+ projects: _("Your Projects (default)"),
+ stars: _("Starred Projects"),
+ project_activity: _("Your Projects' Activity"),
+ starred_project_activity: _("Starred Projects' Activity"),
+ groups: _("Your Groups"),
+ todos: _("Your To-Do List"),
+ issues: _("Assigned Issues"),
+ merge_requests: _("Assigned Merge Requests"),
+ operations: _("Operations Dashboard")
+ }.with_indifferent_access.freeze
+ end
+
def project_view_choices
[
['Files and Readme (default)', :files],
@@ -75,9 +77,9 @@ module PreferencesHelper
# Ensure that anyone adding new options updates `DASHBOARD_CHOICES` too
def validate_dashboard_choices!(user_dashboards)
- if user_dashboards.size != DASHBOARD_CHOICES.size
+ if user_dashboards.size != localized_dashboard_choices.size
raise "`User` defines #{user_dashboards.size} dashboard choices," \
- " but `DASHBOARD_CHOICES` defined #{DASHBOARD_CHOICES.size}."
+ " but `localized_dashboard_choices` defined #{localized_dashboard_choices.size}."
end
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index e700f0dbf2a..3d5f22faf68 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -737,3 +737,5 @@ module ProjectsHelper
can?(current_user, :destroy_container_image, project)
end
end
+
+ProjectsHelper.prepend_if_ee('EE::ProjectsHelper')
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index 3fd865003c1..d4d93ab9795 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -91,6 +91,20 @@ module Emails
end
end
+ def issues_csv_email(user, project, csv_data, export_status)
+ @project = project
+ @issues_count = export_status.fetch(:rows_expected)
+ @written_count = export_status.fetch(:rows_written)
+ @truncated = export_status.fetch(:truncated)
+
+ filename = "#{project.full_path.parameterize}_issues_#{Date.today.iso8601}.csv"
+ attachments[filename] = { content: csv_data, mime_type: 'text/csv' }
+ mail(to: user.notification_email_for(@project.group), subject: subject("Exported issues")) do |format|
+ format.html { render layout: 'mailer' }
+ format.text { render layout: 'mailer' }
+ end
+ end
+
private
def setup_issue_mail(issue_id, recipient_id, closed_via: nil)
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index 114737eb232..38e1d9532a6 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -80,6 +80,10 @@ class NotifyPreview < ActionMailer::Preview
Notify.import_issues_csv_email(user.id, project.id, { success: 3, errors: [5, 6, 7], valid_file: true })
end
+ def issues_csv_email
+ Notify.issues_csv_email(user, project, '1997,Ford,E350', { truncated: false, rows_expected: 3, rows_written: 3 }).message
+ end
+
def closed_merge_request_email
Notify.closed_merge_request_email(user.id, issue.id, user.id).message
end
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index fa0619f35b0..76882dfcb0d 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -3,7 +3,6 @@
module Ci
class Bridge < Ci::Processable
include Ci::Contextable
- include Ci::PipelineDelegator
include Ci::Metadatable
include Importable
include AfterCommitQueue
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 74a329dccf4..8bc75b6c164 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -4,7 +4,6 @@ module Ci
class Build < Ci::Processable
include Ci::Metadatable
include Ci::Contextable
- include Ci::PipelineDelegator
include TokenAuthenticatable
include AfterCommitQueue
include ObjectStorage::BackgroundMove
@@ -526,6 +525,7 @@ module Ci
strong_memoize(:variables) do
Gitlab::Ci::Variables::Collection.new
.concat(persisted_variables)
+ .concat(job_jwt_variables)
.concat(scoped_variables)
.concat(job_variables)
.concat(environment_changed_page_variables)
@@ -591,13 +591,7 @@ module Ci
def merge_request
strong_memoize(:merge_request) do
- merge_requests = MergeRequest.includes(:latest_merge_request_diff)
- .where(source_branch: ref, source_project: pipeline.project)
- .reorder(iid: :desc)
-
- merge_requests.find do |merge_request|
- merge_request.commit_shas.include?(pipeline.sha)
- end
+ pipeline.all_merge_requests.order(iid: :asc).first
end
end
@@ -981,6 +975,15 @@ module Ci
def has_expiring_artifacts?
artifacts_expire_at.present? && artifacts_expire_at > Time.now
end
+
+ def job_jwt_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless Feature.enabled?(:ci_job_jwt, project, default_enabled: true)
+
+ jwt = Gitlab::Ci::Jwt.for_build(self)
+ variables.append(key: 'CI_JOB_JWT', value: jwt, public: false, masked: true)
+ end
+ end
end
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index c4ac10814a9..ef0701b3874 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -73,14 +73,12 @@ module Ci
validates :file_format, presence: true, unless: :trace?, on: :create
validate :valid_file_format?, unless: :trace?, on: :create
-
before_save :set_size, if: :file_changed?
- before_save :set_file_store, if: ->(job_artifact) { job_artifact.file_store.nil? }
-
- after_save :update_file_store, if: :saved_change_to_file?
update_project_statistics project_statistics_name: :build_artifacts_size
+ after_save :update_file_store, if: :saved_change_to_file?
+
scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) }
scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) }
scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) }
@@ -228,15 +226,6 @@ module Ci
self.size = file.size
end
- def set_file_store
- self.file_store =
- if JobArtifactUploader.object_store_enabled? && JobArtifactUploader.direct_upload_enabled?
- JobArtifactUploader::Store::REMOTE
- else
- file.object_store
- end
- end
-
def project_destroyed?
# Use job.project to avoid extra DB query for project
job.project.pending_delete?
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index 4bc8f26ec92..c123bd7c33b 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -51,6 +51,12 @@ module Ci
validates :type, presence: true
validates :scheduling_type, presence: true, on: :create, if: :validate_scheduling_type?
+ delegate :merge_request?,
+ :merge_request_ref?,
+ :legacy_detached_merge_request_pipeline?,
+ :merge_train_pipeline?,
+ to: :pipeline
+
def aggregated_needs_names
read_attribute(:aggregated_needs_names)
end
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 690aa978716..d4e9217ff9f 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -35,6 +35,7 @@ module Ci
AVAILABLE_SCOPES = (AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze
FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze
+ MINUTES_COST_FACTOR_FIELDS = %i[public_projects_minutes_cost_factor private_projects_minutes_cost_factor].freeze
ignore_column :is_shared, remove_after: '2019-12-15', remove_with: '12.6'
@@ -137,6 +138,11 @@ module Ci
numericality: { greater_than_or_equal_to: 600,
message: 'needs to be at least 10 minutes' }
+ validates :public_projects_minutes_cost_factor, :private_projects_minutes_cost_factor,
+ allow_nil: false,
+ numericality: { greater_than_or_equal_to: 0.0,
+ message: 'needs to be non-negative' }
+
# Searches for runners matching the given query.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
diff --git a/app/models/clusters/applications/fluentd.rb b/app/models/clusters/applications/fluentd.rb
new file mode 100644
index 00000000000..a33b1e39ace
--- /dev/null
+++ b/app/models/clusters/applications/fluentd.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class Fluentd < ApplicationRecord
+ VERSION = '2.4.0'
+
+ self.table_name = 'clusters_applications_fluentd'
+
+ include ::Clusters::Concerns::ApplicationCore
+ include ::Clusters::Concerns::ApplicationStatus
+ include ::Clusters::Concerns::ApplicationVersion
+ include ::Clusters::Concerns::ApplicationData
+
+ default_value_for :version, VERSION
+ default_value_for :port, 514
+ default_value_for :protocol, :tcp
+
+ enum protocol: { tcp: 0, udp: 1 }
+
+ def chart
+ 'stable/fluentd'
+ end
+
+ def install_command
+ Gitlab::Kubernetes::Helm::InstallCommand.new(
+ name: 'fluentd',
+ repository: repository,
+ version: VERSION,
+ rbac: cluster.platform_kubernetes_rbac?,
+ chart: chart,
+ files: files
+ )
+ end
+
+ def values
+ content_values.to_yaml
+ end
+
+ private
+
+ def content_values
+ YAML.load_file(chart_values_file).deep_merge!(specification)
+ end
+
+ def specification
+ {
+ "configMaps" => {
+ "output.conf" => output_configuration_content,
+ "general.conf" => general_configuration_content
+ }
+ }
+ end
+
+ def output_configuration_content
+ <<~EOF
+ <match kubernetes.**>
+ @type remote_syslog
+ @id out_kube_remote_syslog
+ host #{host}
+ port #{port}
+ program fluentd
+ hostname ${kubernetes_host}
+ protocol #{protocol}
+ packet_size 65535
+ <buffer kubernetes_host>
+ </buffer>
+ <format>
+ @type ltsv
+ </format>
+ </match>
+ EOF
+ end
+
+ def general_configuration_content
+ <<~EOF
+ <match fluent.**>
+ @type null
+ </match>
+ <source>
+ @type http
+ port 9880
+ bind 0.0.0.0
+ </source>
+ <source>
+ @type tail
+ @id in_tail_container_logs
+ path /var/log/containers/*#{Ingress::MODSECURITY_LOG_CONTAINER_NAME}*.log
+ pos_file /var/log/fluentd-containers.log.pos
+ tag kubernetes.*
+ read_from_head true
+ <parse>
+ @type json
+ time_format %Y-%m-%dT%H:%M:%S.%NZ
+ </parse>
+ </source>
+ EOF
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index baf34e916f8..5985e08d73e 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -30,7 +30,6 @@ module Clusters
enum modsecurity_mode: { logging: 0, blocking: 1 }
FETCH_IP_ADDRESS_DELAY = 30.seconds
- MODSEC_SIDECAR_INITIAL_DELAY_SECONDS = 10
state_machine :status do
after_transition any => [:installed] do |application|
@@ -108,11 +107,13 @@ module Clusters
"readOnly" => true
}
],
- "startupProbe" => {
+ "livenessProbe" => {
"exec" => {
- "command" => ["ls", "/var/log/modsec"]
- },
- "initialDelaySeconds" => MODSEC_SIDECAR_INITIAL_DELAY_SECONDS
+ "command" => [
+ "ls",
+ "/var/log/modsec/audit.log"
+ ]
+ }
}
}
],
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 9ef3d64f21a..430a9b3c43e 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -19,7 +19,8 @@ module Clusters
Clusters::Applications::Runner.application_name => Clusters::Applications::Runner,
Clusters::Applications::Jupyter.application_name => Clusters::Applications::Jupyter,
Clusters::Applications::Knative.application_name => Clusters::Applications::Knative,
- Clusters::Applications::ElasticStack.application_name => Clusters::Applications::ElasticStack
+ Clusters::Applications::ElasticStack.application_name => Clusters::Applications::ElasticStack,
+ Clusters::Applications::Fluentd.application_name => Clusters::Applications::Fluentd
}.freeze
DEFAULT_ENVIRONMENT = '*'
KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN'
@@ -57,6 +58,7 @@ module Clusters
has_one_cluster_application :jupyter
has_one_cluster_application :knative
has_one_cluster_application :elastic_stack
+ has_one_cluster_application :fluentd
has_many :kubernetes_namespaces
has_many :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', inverse_of: :cluster
diff --git a/app/models/concerns/ci/has_ref.rb b/app/models/concerns/ci/has_ref.rb
index cf57ff47743..e2d459ea70e 100644
--- a/app/models/concerns/ci/has_ref.rb
+++ b/app/models/concerns/ci/has_ref.rb
@@ -2,7 +2,7 @@
##
# We will disable `ref` and `sha` attributes in `Ci::Build` in the future
-# and remove this module in favor of Ci::PipelineDelegator.
+# and remove this module in favor of Ci::Processable.
module Ci
module HasRef
extend ActiveSupport::Concern
diff --git a/app/models/concerns/ci/pipeline_delegator.rb b/app/models/concerns/ci/pipeline_delegator.rb
deleted file mode 100644
index 68ad0fcee31..00000000000
--- a/app/models/concerns/ci/pipeline_delegator.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-##
-# This module is mainly used by child associations of `Ci::Pipeline` that needs to look up
-# single source of truth. For example, `Ci::Build` has `git_ref` method, which behaves
-# slightly different from `Ci::Pipeline`'s `git_ref`. This is very confusing as
-# the system could behave differently time to time.
-# We should have a single interface in `Ci::Pipeline` and access the method always.
-module Ci
- module PipelineDelegator
- extend ActiveSupport::Concern
-
- included do
- delegate :merge_request?,
- :merge_request_ref?,
- :legacy_detached_merge_request_pipeline?,
- :merge_train_pipeline?, to: :pipeline
- end
- end
-end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 7300283f086..37f2209b9d2 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -116,6 +116,7 @@ module Issuable
# rubocop:enable GitlabSecurity/SqlInjection
scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) }
+ scope :with_label_ids, ->(label_ids) { joins(:label_links).where(label_links: { label_id: label_ids }) }
scope :any_label, -> { joins(:label_links).group(:id) }
scope :join_project, -> { joins(:project) }
scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) }
@@ -131,8 +132,21 @@ module Issuable
strip_attributes :title
- def self.locking_enabled?
- false
+ class << self
+ def labels_hash
+ issue_labels = Hash.new { |h, k| h[k] = [] }
+
+ relation = unscoped.where(id: self.select(:id)).eager_load(:labels)
+ relation.pluck(:id, 'labels.title').each do |issue_id, label|
+ issue_labels[issue_id] << label if label.present?
+ end
+
+ issue_labels
+ end
+
+ def locking_enabled?
+ false
+ end
end
# We want to use optimistic lock for cases when only title or description are involved
@@ -478,5 +492,4 @@ module Issuable
end
end
-Issuable.prepend_if_ee('EE::Issuable') # rubocop: disable Cop/InjectEnterpriseEditionModule
-Issuable::ClassMethods.prepend_if_ee('EE::Issuable::ClassMethods')
+Issuable.prepend_if_ee('EE::Issuable')
diff --git a/app/models/concerns/notification_branch_selection.rb b/app/models/concerns/notification_branch_selection.rb
index 7f00b652530..2354335469a 100644
--- a/app/models/concerns/notification_branch_selection.rb
+++ b/app/models/concerns/notification_branch_selection.rb
@@ -6,12 +6,14 @@
module NotificationBranchSelection
extend ActiveSupport::Concern
- BRANCH_CHOICES = [
- [_('All branches'), 'all'],
- [_('Default branch'), 'default'],
- [_('Protected branches'), 'protected'],
- [_('Default branch and protected branches'), 'default_and_protected']
- ].freeze
+ def branch_choices
+ [
+ [_('All branches'), 'all'].freeze,
+ [_('Default branch'), 'default'].freeze,
+ [_('Protected branches'), 'protected'].freeze,
+ [_('Default branch and protected branches'), 'default_and_protected'].freeze
+ ].freeze
+ end
def notify_for_branch?(data)
ref = if data[:ref]
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index 76d26500267..cedcf164a49 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -66,6 +66,10 @@ module ProjectFeaturesCompatibility
write_feature_attribute_string(:pages_access_level, value)
end
+ def metrics_dashboard_access_level=(value)
+ write_feature_attribute_string(:metrics_dashboard_access_level, value)
+ end
+
private
def write_feature_attribute_boolean(field, value)
diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb
index 93e3ebf7896..f9e2f00b9f3 100644
--- a/app/models/diff_discussion.rb
+++ b/app/models/diff_discussion.rb
@@ -13,6 +13,7 @@ class DiffDiscussion < Discussion
delegate :position,
:original_position,
:change_position,
+ :diff_note_positions,
:on_text?,
:on_image?,
diff --git a/app/models/diff_note_position.rb b/app/models/diff_note_position.rb
index 716a56c6430..a25b0def643 100644
--- a/app/models/diff_note_position.rb
+++ b/app/models/diff_note_position.rb
@@ -2,6 +2,7 @@
class DiffNotePosition < ApplicationRecord
belongs_to :note
+ attr_accessor :line_range
enum diff_content_type: {
text: 0,
@@ -42,6 +43,7 @@ class DiffNotePosition < ApplicationRecord
def self.position_to_attrs(position)
position_attrs = position.to_h
position_attrs[:diff_content_type] = position_attrs.delete(:position_type)
+ position_attrs.delete(:line_range)
position_attrs
end
end
diff --git a/app/models/group.rb b/app/models/group.rb
index f4eaa581d54..55a2c4ba9a9 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -72,7 +72,7 @@ class Group < Namespace
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :name,
format: { with: Gitlab::Regex.group_name_regex,
- message: Gitlab::Regex.group_name_regex_message }
+ message: Gitlab::Regex.group_name_regex_message }, if: :name_changed?
add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
diff --git a/app/models/import_failure.rb b/app/models/import_failure.rb
index a1e03218640..109c0c82487 100644
--- a/app/models/import_failure.rb
+++ b/app/models/import_failure.rb
@@ -6,4 +6,11 @@ class ImportFailure < ApplicationRecord
validates :project, presence: true, unless: :group
validates :group, presence: true, unless: :project
+
+ # Returns any `import_failures` for relations that were unrecoverable errors or failed after
+ # several retries. An import can be successful even if some relations failed to import correctly.
+ # A retry_count of 0 indicates that either no retries were attempted, or they were exceeded.
+ scope :hard_failures_by_correlation_id, ->(correlation_id) {
+ where(correlation_id_value: correlation_id, retry_count: 0).order(created_at: :desc)
+ }
end
diff --git a/app/models/jira_import_state.rb b/app/models/jira_import_state.rb
index 543ee77917c..bde2795e7b8 100644
--- a/app/models/jira_import_state.rb
+++ b/app/models/jira_import_state.rb
@@ -53,6 +53,7 @@ class JiraImportState < ApplicationRecord
before_transition any => :finished do |state, _|
InternalId.flush_records!(project: state.project)
state.project.update_project_counter_caches
+ state.store_issue_counts
end
after_transition any => :finished do |state, _|
@@ -80,4 +81,20 @@ class JiraImportState < ApplicationRecord
def non_initial?
!initial?
end
+
+ def store_issue_counts
+ import_label_id = Gitlab::JiraImport.get_import_label_id(project.id)
+
+ failed_to_import_count = Gitlab::JiraImport.issue_failures(project.id)
+ successfully_imported_count = project.issues.with_label_ids(import_label_id).count
+ total_issue_count = successfully_imported_count + failed_to_import_count
+
+ update(
+ {
+ failed_to_import_count: failed_to_import_count,
+ imported_issues_count: successfully_imported_count,
+ total_issue_count: total_issue_count
+ }
+ )
+ end
end
diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb
index c5233deaa96..6a86aebae39 100644
--- a/app/models/lfs_object.rb
+++ b/app/models/lfs_object.rb
@@ -17,8 +17,6 @@ class LfsObject < ApplicationRecord
mount_uploader :file, LfsObjectUploader
- before_save :set_file_store, if: ->(lfs_object) { lfs_object.file_store.nil? }
-
after_save :update_file_store, if: :saved_change_to_file?
def self.not_linked_to_project(project)
@@ -57,17 +55,6 @@ class LfsObject < ApplicationRecord
def self.calculate_oid(path)
self.hexdigest(path)
end
-
- private
-
- def set_file_store
- self.file_store =
- if LfsObjectUploader.object_store_enabled? && LfsObjectUploader.direct_upload_enabled?
- LfsObjectUploader::Store::REMOTE
- else
- file.object_store
- end
- end
end
LfsObject.prepend_if_ee('EE::LfsObject')
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index b9acb539404..9939167e74f 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -167,20 +167,22 @@ class MergeRequest < ApplicationRecord
end
event :mark_as_checking do
- transition [:unchecked, :cannot_be_merged_recheck] => :checking
+ transition unchecked: :checking
+ transition cannot_be_merged_recheck: :cannot_be_merged_rechecking
end
event :mark_as_mergeable do
- transition [:unchecked, :cannot_be_merged_recheck, :checking] => :can_be_merged
+ transition [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking] => :can_be_merged
end
event :mark_as_unmergeable do
- transition [:unchecked, :cannot_be_merged_recheck, :checking] => :cannot_be_merged
+ transition [:unchecked, :cannot_be_merged_recheck, :checking, :cannot_be_merged_rechecking] => :cannot_be_merged
end
state :unchecked
state :cannot_be_merged_recheck
state :checking
+ state :cannot_be_merged_rechecking
state :can_be_merged
state :cannot_be_merged
@@ -189,7 +191,7 @@ class MergeRequest < ApplicationRecord
end
# rubocop: disable CodeReuse/ServiceClass
- after_transition unchecked: :cannot_be_merged do |merge_request, transition|
+ after_transition [:unchecked, :checking] => :cannot_be_merged do |merge_request, transition|
if merge_request.notify_conflict?
NotificationService.new.merge_request_unmergeable(merge_request)
TodoService.new.merge_request_became_unmergeable(merge_request)
@@ -202,6 +204,12 @@ class MergeRequest < ApplicationRecord
end
end
+ # Returns current merge_status except it returns `cannot_be_merged_rechecking` as `checking`
+ # to avoid exposing unnecessary internal state
+ def public_merge_status
+ cannot_be_merged_rechecking? ? 'checking' : merge_status
+ end
+
validates :source_project, presence: true, unless: [:allow_broken, :importing?, :closed_without_fork?]
validates :source_branch, presence: true
validates :target_project, presence: true
@@ -569,13 +577,13 @@ class MergeRequest < ApplicationRecord
merge_request_diff&.real_size || diff_stats&.real_size || diffs.real_size
end
- def modified_paths(past_merge_request_diff: nil)
+ def modified_paths(past_merge_request_diff: nil, fallback_on_overflow: false)
if past_merge_request_diff
- past_merge_request_diff.modified_paths
+ past_merge_request_diff.modified_paths(fallback_on_overflow: fallback_on_overflow)
elsif compare
diff_stats&.paths || compare.modified_paths
else
- merge_request_diff.modified_paths
+ merge_request_diff.modified_paths(fallback_on_overflow: fallback_on_overflow)
end
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 9136c6cc5d4..7b15d21c095 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -366,9 +366,22 @@ class MergeRequestDiff < ApplicationRecord
end
# rubocop: enable CodeReuse/ServiceClass
- def modified_paths
- strong_memoize(:modified_paths) do
- merge_request_diff_files.pluck(:new_path, :old_path).flatten.uniq
+ def modified_paths(fallback_on_overflow: false)
+ if fallback_on_overflow && overflow?
+ # This is an extremely slow means to find the modified paths for a given
+ # MergeRequestDiff. This should be avoided, except where the limit of
+ # 1_000 (as of %12.10) entries returned by the default behavior is an
+ # issue.
+ strong_memoize(:overflowed_modified_paths) do
+ project.repository.diff_stats(
+ base_commit_sha,
+ head_commit_sha
+ ).paths
+ end
+ else
+ strong_memoize(:modified_paths) do
+ merge_request_diff_files.pluck(:new_path, :old_path).flatten.uniq
+ end
end
end
diff --git a/app/models/metrics/dashboard/annotation.rb b/app/models/metrics/dashboard/annotation.rb
index 2f1b6527742..8166880f0c9 100644
--- a/app/models/metrics/dashboard/annotation.rb
+++ b/app/models/metrics/dashboard/annotation.rb
@@ -15,6 +15,11 @@ module Metrics
validate :single_ownership
validate :orphaned_annotation
+ scope :after, ->(after) { where('starting_at >= ?', after) }
+ scope :before, ->(before) { where('starting_at <= ?', before) }
+
+ scope :for_dashboard, ->(dashboard_path) { where(dashboard_path: dashboard_path) }
+
private
def single_ownership
diff --git a/app/models/note.rb b/app/models/note.rb
index e6ad7c2227f..a2a711c987f 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -125,7 +125,7 @@ class Note < ApplicationRecord
scope :inc_author, -> { includes(:author) }
scope :inc_relations_for_view, -> do
includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji,
- { system_note_metadata: :description_version }, :note_diff_file, :suggestions)
+ { system_note_metadata: :description_version }, :note_diff_file, :diff_note_positions, :suggestions)
end
scope :with_notes_filter, -> (notes_filter) do
diff --git a/app/models/project.rb b/app/models/project.rb
index 443b44dd023..79785bfce85 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -340,7 +340,7 @@ class Project < ApplicationRecord
:pages_enabled?, :public_pages?, :private_pages?,
:merge_requests_access_level, :forking_access_level, :issues_access_level,
:wiki_access_level, :snippets_access_level, :builds_access_level,
- :repository_access_level, :pages_access_level,
+ :repository_access_level, :pages_access_level, :metrics_dashboard_access_level,
to: :project_feature, allow_nil: true
delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?,
prefix: :import, to: :import_state, allow_nil: true
@@ -415,7 +415,6 @@ class Project < ApplicationRecord
scope :sorted_by_activity, -> { reorder(Arel.sql("GREATEST(COALESCE(last_activity_at, '1970-01-01'), COALESCE(last_repository_updated_at, '1970-01-01')) DESC")) }
scope :sorted_by_stars_desc, -> { reorder(self.arel_table['star_count'].desc) }
scope :sorted_by_stars_asc, -> { reorder(self.arel_table['star_count'].asc) }
- scope :sorted_by_name_asc_limited, ->(limit) { reorder(name: :asc).limit(limit) }
# Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name
scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) }
@@ -591,7 +590,7 @@ class Project < ApplicationRecord
#
# query - The search query as a String.
def search(query, include_namespace: false)
- if include_namespace && Feature.enabled?(:project_search_by_full_path, default_enabled: true)
+ if include_namespace
joins(:route).fuzzy_search(query, [Route.arel_table[:path], Route.arel_table[:name], :description])
else
fuzzy_search(query, [:path, :name, :description])
@@ -774,10 +773,6 @@ class Project < ApplicationRecord
{ scope: :project, status: auto_devops&.enabled || Feature.enabled?(:force_autodevops_on_by_default, self) }
end
- def daily_statistics_enabled?
- Feature.enabled?(:project_daily_statistics, self, default_enabled: true)
- end
-
def unlink_forks_upon_visibility_decrease_enabled?
Feature.enabled?(:unlink_fork_network_upon_visibility_decrease, self, default_enabled: true)
end
@@ -866,6 +861,16 @@ class Project < ApplicationRecord
latest_jira_import&.status || 'initial'
end
+ def validate_jira_import_settings!(user: nil)
+ raise Projects::ImportService::Error, _('Jira import feature is disabled.') unless jira_issues_import_feature_flag_enabled?
+ raise Projects::ImportService::Error, _('Jira integration not configured.') unless jira_service&.active?
+
+ return unless user
+
+ raise Projects::ImportService::Error, _('Cannot import because issues are not available in this project.') unless feature_available?(:issues, user)
+ raise Projects::ImportService::Error, _('You do not have permissions to run the import.') unless user.can?(:admin_project, self)
+ end
+
def human_import_status_name
import_state&.human_status_name || 'none'
end
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index a9753c3c53a..31a3fa12c00 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -22,7 +22,7 @@ class ProjectFeature < ApplicationRecord
ENABLED = 20
PUBLIC = 30
- FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages).freeze
+ FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard).freeze
PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze
PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze
STRING_OPTIONS = HashWithIndifferentAccess.new({
@@ -90,13 +90,14 @@ class ProjectFeature < ApplicationRecord
validate :repository_children_level
validate :allowed_access_levels
- default_value_for :builds_access_level, value: ENABLED, allows_nil: false
- default_value_for :issues_access_level, value: ENABLED, allows_nil: false
- default_value_for :forking_access_level, value: ENABLED, allows_nil: false
- default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false
- default_value_for :snippets_access_level, value: ENABLED, allows_nil: false
- default_value_for :wiki_access_level, value: ENABLED, allows_nil: false
- default_value_for :repository_access_level, value: ENABLED, allows_nil: false
+ default_value_for :builds_access_level, value: ENABLED, allows_nil: false
+ default_value_for :issues_access_level, value: ENABLED, allows_nil: false
+ default_value_for :forking_access_level, value: ENABLED, allows_nil: false
+ default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false
+ default_value_for :snippets_access_level, value: ENABLED, allows_nil: false
+ default_value_for :wiki_access_level, value: ENABLED, allows_nil: false
+ default_value_for :repository_access_level, value: ENABLED, allows_nil: false
+ default_value_for :metrics_dashboard_access_level, value: PRIVATE, allows_nil: false
default_value_for(:pages_access_level, allows_nil: false) do |feature|
if ::Gitlab::Pages.access_control_is_forced?
diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb
index f58b8dc624d..e434ea58729 100644
--- a/app/models/project_import_state.rb
+++ b/app/models/project_import_state.rb
@@ -72,6 +72,10 @@ class ProjectImportState < ApplicationRecord
end
end
+ def relation_hard_failures(limit:)
+ project.import_failures.hard_failures_by_correlation_id(correlation_id).limit(limit)
+ end
+
def mark_as_failed(error_message)
original_errors = errors.dup
sanitized_message = Gitlab::UrlSanitizer.sanitize(error_message)
diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb
index 1ec983223f3..c9e97efb4ac 100644
--- a/app/models/project_services/chat_notification_service.rb
+++ b/app/models/project_services/chat_notification_service.rb
@@ -59,11 +59,11 @@ class ChatNotificationService < Service
def default_fields
[
- { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true },
- { type: 'text', name: 'username', placeholder: 'e.g. GitLab' },
- { type: 'checkbox', name: 'notify_only_broken_pipelines' },
- { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }
- ]
+ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}", required: true }.freeze,
+ { type: 'text', name: 'username', placeholder: 'e.g. GitLab' }.freeze,
+ { type: 'checkbox', name: 'notify_only_broken_pipelines' }.freeze,
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }.freeze
+ ].freeze
end
def execute(data)
diff --git a/app/models/project_services/discord_service.rb b/app/models/project_services/discord_service.rb
index 294b286f073..941b7f64263 100644
--- a/app/models/project_services/discord_service.rb
+++ b/app/models/project_services/discord_service.rb
@@ -44,7 +44,7 @@ class DiscordService < ChatNotificationService
[
{ type: "text", name: "webhook", placeholder: "e.g. https://discordapp.com/api/webhooks/…" },
{ type: "checkbox", name: "notify_only_broken_pipelines" },
- { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
]
end
diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb
index dd2f1359e76..01d8647d439 100644
--- a/app/models/project_services/emails_on_push_service.rb
+++ b/app/models/project_services/emails_on_push_service.rb
@@ -66,7 +66,7 @@ class EmailsOnPushService < Service
help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. %{domains}).") % { domains: domains } },
{ type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"),
help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") },
- { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES },
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices },
{ type: 'textarea', name: 'recipients', placeholder: s_('EmailsOnPushService|Emails separated by whitespace') }
]
end
diff --git a/app/models/project_services/hangouts_chat_service.rb b/app/models/project_services/hangouts_chat_service.rb
index d105bd012d6..299a306add7 100644
--- a/app/models/project_services/hangouts_chat_service.rb
+++ b/app/models/project_services/hangouts_chat_service.rb
@@ -44,7 +44,7 @@ class HangoutsChatService < ChatNotificationService
[
{ type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
- { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
]
end
diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb
index 3f7e8a720aa..f5d6ae10469 100644
--- a/app/models/project_services/issue_tracker_service.rb
+++ b/app/models/project_services/issue_tracker_service.rb
@@ -172,7 +172,7 @@ class IssueTrackerService < Service
end
def one_issue_tracker
- return if template?
+ return if template? || instance?
return if project.blank?
if project.services.external_issue_trackers.where.not(id: id).any?
diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb
index 111d010d672..e8e12a9a206 100644
--- a/app/models/project_services/microsoft_teams_service.rb
+++ b/app/models/project_services/microsoft_teams_service.rb
@@ -42,7 +42,7 @@ class MicrosoftTeamsService < ChatNotificationService
[
{ type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
- { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
]
end
diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb
index b5e5afb6ea5..a58a264de5e 100644
--- a/app/models/project_services/pipelines_email_service.rb
+++ b/app/models/project_services/pipelines_email_service.rb
@@ -72,7 +72,7 @@ class PipelinesEmailService < Service
name: 'notify_only_broken_pipelines' },
{ type: 'select',
name: 'branches_to_be_notified',
- choices: BRANCH_CHOICES }
+ choices: branch_choices }
]
end
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
index 1a85289a04f..4a28d1ff2b0 100644
--- a/app/models/project_services/prometheus_service.rb
+++ b/app/models/project_services/prometheus_service.rb
@@ -36,10 +36,6 @@ class PrometheusService < MonitoringService
false
end
- def editable?
- manual_configuration? || !prometheus_available?
- end
-
def title
'Prometheus'
end
@@ -53,8 +49,6 @@ class PrometheusService < MonitoringService
end
def fields
- return [] unless editable?
-
[
{
type: 'checkbox',
diff --git a/app/models/project_services/unify_circuit_service.rb b/app/models/project_services/unify_circuit_service.rb
index 06f2d10f83b..1e12179e62a 100644
--- a/app/models/project_services/unify_circuit_service.rb
+++ b/app/models/project_services/unify_circuit_service.rb
@@ -38,7 +38,7 @@ class UnifyCircuitService < ChatNotificationService
[
{ type: 'text', name: 'webhook', placeholder: "e.g. https://circuit.com/rest/v2/webhooks/incoming/…", required: true },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
- { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES }
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
]
end
diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb
index 8e66310f0c5..cd47c154eef 100644
--- a/app/models/resource_label_event.rb
+++ b/app/models/resource_label_event.rb
@@ -56,7 +56,7 @@ class ResourceLabelEvent < ResourceEvent
end
def banzai_render_context(field)
- super.merge(pipeline: 'label', only_path: true)
+ super.merge(pipeline: :label, only_path: true)
end
def refresh_invalid_reference
diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb
index b97c02f1713..a40af22061e 100644
--- a/app/models/resource_milestone_event.rb
+++ b/app/models/resource_milestone_event.rb
@@ -13,9 +13,9 @@ class ResourceMilestoneEvent < ResourceEvent
validate :exactly_one_issuable
enum action: {
- add: 1,
- remove: 2
- }
+ add: 1,
+ remove: 2
+ }
# state is used for issue and merge request states.
enum state: Issue.available_states.merge(MergeRequest.available_states)
diff --git a/app/models/route.rb b/app/models/route.rb
index 91ea2966013..63a0461807b 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -2,9 +2,9 @@
class Route < ApplicationRecord
include CaseSensitivity
+ include Gitlab::SQL::Pattern
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
-
validates :source, presence: true
validates :path,
@@ -19,6 +19,8 @@ class Route < ApplicationRecord
after_update :rename_descendants
scope :inside_path, -> (path) { where('routes.path LIKE ?', "#{sanitize_sql_like(path)}/%") }
+ scope :for_routable, -> (routable) { where(source: routable) }
+ scope :sort_by_path_length, -> { order('LENGTH(routes.path)', :path) }
def rename_descendants
return unless saved_change_to_path? || saved_change_to_name?
diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb
index 8ca4ee9239a..c4e047ff9d1 100644
--- a/app/models/terraform/state.rb
+++ b/app/models/terraform/state.rb
@@ -2,14 +2,25 @@
module Terraform
class State < ApplicationRecord
+ DEFAULT = '{"version":1}'.freeze
+ HEX_REGEXP = %r{\A\h+\z}.freeze
+ UUID_LENGTH = 32
+
belongs_to :project
+ belongs_to :locked_by_user, class_name: 'User'
validates :project_id, presence: true
+ validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH },
+ format: { with: HEX_REGEXP, message: 'only allows hex characters' }
+
+ default_value_for(:uuid, allows_nil: false) { SecureRandom.hex(UUID_LENGTH / 2) }
after_save :update_file_store, if: :saved_change_to_file?
mount_uploader :file, StateUploader
+ default_value_for(:file) { CarrierWaveStringFile.new(DEFAULT) }
+
def update_file_store
# The file.object_store is set during `uploader.store!`
# which happens after object is inserted/updated
@@ -19,5 +30,9 @@ module Terraform
def file_store
super || StateUploader.default_store
end
+
+ def locked?
+ self.lock_xid.present?
+ end
end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 42972477d97..1b087da3a2f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -337,7 +337,8 @@ class User < ApplicationRecord
scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) }
scope :with_public_profile, -> { where(private_profile: false) }
scope :bots, -> { where(user_type: UserTypeEnums.bots.values) }
- scope :not_bots, -> { humans.or(where.not(user_type: UserTypeEnums.bots.values)) }
+ scope :bots_without_project_bot, -> { bots.where.not(user_type: UserTypeEnums.bots[:project_bot]) }
+ scope :with_project_bots, -> { humans.or(where.not(user_type: UserTypeEnums.bots.except(:project_bot).values)) }
scope :humans, -> { where(user_type: nil) }
scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do
@@ -657,8 +658,10 @@ class User < ApplicationRecord
UserTypeEnums.bots.has_key?(user_type)
end
+ # The explicit check for project_bot will be removed with Bot Categorization
+ # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
def internal?
- ghost? || bot?
+ ghost? || (bot? && !project_bot?)
end
# We are transitioning from ghost boolean column to user_type
@@ -668,12 +671,16 @@ class User < ApplicationRecord
ghost
end
+ # The explicit check for project_bot will be removed with Bot Categorization
+ # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
def self.internal
- where(ghost: true).or(bots)
+ where(ghost: true).or(bots_without_project_bot)
end
+ # The explicit check for project_bot will be removed with Bot Categorization
+ # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
def self.non_internal
- without_ghosts.not_bots
+ without_ghosts.with_project_bots
end
#
@@ -1720,7 +1727,7 @@ class User < ApplicationRecord
# override, from Devise::Validatable
def password_required?
- return false if internal?
+ return false if internal? || project_bot?
super
end
diff --git a/app/models/user_type_enums.rb b/app/models/user_type_enums.rb
index 795cc4b2889..cb5aac89ed3 100644
--- a/app/models/user_type_enums.rb
+++ b/app/models/user_type_enums.rb
@@ -6,7 +6,7 @@ module UserTypeEnums
end
def self.bots
- @bots ||= { alert_bot: 2 }.with_indifferent_access
+ @bots ||= { alert_bot: 2, project_bot: 6 }.with_indifferent_access
end
end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 2bde7bcca08..9353b361c2a 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -17,6 +17,8 @@ class GlobalPolicy < BasePolicy
condition(:private_instance_statistics, score: 0) { Gitlab::CurrentSettings.instance_statistics_visibility_private? }
+ condition(:project_bot, scope: :user) { @user&.project_bot? }
+
rule { admin | (~private_instance_statistics & ~anonymous) }
.enable :read_instance_statistics
@@ -51,6 +53,11 @@ class GlobalPolicy < BasePolicy
prevent :use_slash_commands
end
+ rule { project_bot }.policy do
+ prevent :log_in
+ prevent :receive_notifications
+ end
+
rule { deactivated }.policy do
prevent :access_git
prevent :access_api
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index a34217d90dd..728c4b76498 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -91,6 +91,7 @@ class GroupPolicy < BasePolicy
end
rule { reporter }.policy do
+ enable :reporter_access
enable :read_container_image
enable :download_wiki_code
enable :admin_label
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index ce9a3346b4b..395eaeea8de 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -36,16 +36,18 @@ module Ci
end
end
- NAMES = {
- merge_train: s_('Pipeline|Merge train pipeline'),
- merged_result: s_('Pipeline|Merged result pipeline'),
- detached: s_('Pipeline|Detached merge request pipeline')
- }.freeze
+ def localized_names
+ {
+ merge_train: s_('Pipeline|Merge train pipeline'),
+ merged_result: s_('Pipeline|Merged result pipeline'),
+ detached: s_('Pipeline|Detached merge request pipeline')
+ }.freeze
+ end
def name
# Currently, `merge_request_event_type` is the only source to name pipelines
# but this could be extended with the other types in the future.
- NAMES.fetch(pipeline.merge_request_event_type, s_('Pipeline|Pipeline'))
+ localized_names.fetch(pipeline.merge_request_event_type, s_('Pipeline|Pipeline'))
end
def ref_text
diff --git a/app/serializers/analytics_summary_entity.rb b/app/serializers/analytics_summary_entity.rb
index b9797bfb021..57e9225e2da 100644
--- a/app/serializers/analytics_summary_entity.rb
+++ b/app/serializers/analytics_summary_entity.rb
@@ -4,4 +4,12 @@ class AnalyticsSummaryEntity < Grape::Entity
expose :value, safe: true
expose :title
expose :unit, if: { with_unit: true }
+
+ private
+
+ def value
+ return object.value if object.value.is_a? String
+
+ object.value&.nonzero? ? object.value.to_s : '-'
+ end
end
diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb
index c08691c6bcf..85a40f1f5cb 100644
--- a/app/serializers/cluster_application_entity.rb
+++ b/app/serializers/cluster_application_entity.rb
@@ -16,4 +16,7 @@ class ClusterApplicationEntity < Grape::Entity
expose :available_domains, using: Serverless::DomainEntity, if: -> (e, _) { e.respond_to?(:available_domains) }
expose :pages_domain, using: Serverless::DomainEntity, if: -> (e, _) { e.respond_to?(:pages_domain) }
expose :modsecurity_mode, if: -> (e, _) { e.respond_to?(:modsecurity_mode) }
+ expose :host, if: -> (e, _) { e.respond_to?(:host) }
+ expose :port, if: -> (e, _) { e.respond_to?(:port) }
+ expose :protocol, if: -> (e, _) { e.respond_to?(:protocol) }
end
diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb
index e302672042e..77881eaba0c 100644
--- a/app/serializers/discussion_entity.rb
+++ b/app/serializers/discussion_entity.rb
@@ -20,6 +20,14 @@ class DiscussionEntity < Grape::Entity
discussion_path(discussion)
end
+ expose :positions, if: -> (d, _) { display_merge_ref_discussions?(d) } do |discussion|
+ discussion.diff_note_positions.map(&:position)
+ end
+
+ expose :line_codes, if: -> (d, _) { display_merge_ref_discussions?(d) } do |discussion|
+ discussion.diff_note_positions.map(&:line_code)
+ end
+
expose :individual_note?, as: :individual_note
expose :resolvable do |discussion|
discussion.resolvable?
@@ -59,4 +67,11 @@ class DiscussionEntity < Grape::Entity
def current_user
request.current_user
end
+
+ def display_merge_ref_discussions?(discussion)
+ return unless discussion.diff_discussion?
+ return if discussion.legacy_diff_discussion?
+
+ Feature.enabled?(:merge_ref_head_comments, discussion.project)
+ end
end
diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb
index 973e971b4c0..82baf4a4a78 100644
--- a/app/serializers/merge_request_basic_entity.rb
+++ b/app/serializers/merge_request_basic_entity.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class MergeRequestBasicEntity < Grape::Entity
- expose :merge_status
+ expose :public_merge_status, as: :merge_status
expose :merge_error
expose :state
expose :source_branch_exists?, as: :source_branch_exists
diff --git a/app/serializers/merge_request_poll_cached_widget_entity.rb b/app/serializers/merge_request_poll_cached_widget_entity.rb
index 2f8eb6650e8..72f629b3507 100644
--- a/app/serializers/merge_request_poll_cached_widget_entity.rb
+++ b/app/serializers/merge_request_poll_cached_widget_entity.rb
@@ -6,7 +6,7 @@ class MergeRequestPollCachedWidgetEntity < IssuableEntity
expose :merge_commit_sha
expose :short_merge_commit_sha
expose :merge_error
- expose :merge_status
+ expose :public_merge_status, as: :merge_status
expose :merge_user_id
expose :source_branch
expose :source_project_id
diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb
index aa67cd1f39e..9fd50c8c51d 100644
--- a/app/serializers/merge_request_serializer.rb
+++ b/app/serializers/merge_request_serializer.rb
@@ -15,6 +15,10 @@ class MergeRequestSerializer < BaseSerializer
MergeRequestBasicEntity
when 'noteable'
MergeRequestNoteableEntity
+ when 'poll_cached_widget'
+ MergeRequestPollCachedWidgetEntity
+ when 'poll_widget'
+ MergeRequestPollWidgetEntity
else
# fallback to widget for old poll requests without `serializer` set
MergeRequestWidgetEntity
diff --git a/app/serializers/route_entity.rb b/app/serializers/route_entity.rb
new file mode 100644
index 00000000000..158fda5e00e
--- /dev/null
+++ b/app/serializers/route_entity.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class RouteEntity < Grape::Entity
+ expose :id
+ expose :source_id
+ expose :source_type
+ expose :path
+end
diff --git a/app/serializers/route_serializer.rb b/app/serializers/route_serializer.rb
new file mode 100644
index 00000000000..0b187588301
--- /dev/null
+++ b/app/serializers/route_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class RouteSerializer < BaseSerializer
+ entity RouteEntity
+end
diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb
index e08b4ac2260..1de2f31f87c 100644
--- a/app/services/auto_merge/base_service.rb
+++ b/app/services/auto_merge/base_service.rb
@@ -49,6 +49,14 @@ module AutoMerge
end
end
+ def available_for?(merge_request)
+ strong_memoize("available_for_#{merge_request.id}") do
+ merge_request.can_be_merged_by?(current_user) &&
+ merge_request.mergeable_state?(skip_ci_check: true) &&
+ yield
+ end
+ end
+
private
def strategy
diff --git a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
index 7c0e9228b28..9ae5bd1b5ec 100644
--- a/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
+++ b/app/services/auto_merge/merge_when_pipeline_succeeds_service.rb
@@ -30,7 +30,9 @@ module AutoMerge
end
def available_for?(merge_request)
- merge_request.actual_head_pipeline&.active?
+ super do
+ merge_request.actual_head_pipeline&.active?
+ end
end
end
end
diff --git a/app/services/auto_merge_service.rb b/app/services/auto_merge_service.rb
index eee227be202..c5cbcc7c93b 100644
--- a/app/services/auto_merge_service.rb
+++ b/app/services/auto_merge_service.rb
@@ -1,23 +1,26 @@
# frozen_string_literal: true
class AutoMergeService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS = 'merge_when_pipeline_succeeds'
STRATEGIES = [STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS].freeze
class << self
- def all_strategies
+ def all_strategies_ordered_by_preference
STRATEGIES
end
def get_service_class(strategy)
- return unless all_strategies.include?(strategy)
+ return unless all_strategies_ordered_by_preference.include?(strategy)
"::AutoMerge::#{strategy.camelize}Service".constantize
end
end
- def execute(merge_request, strategy)
- service = get_service_instance(strategy)
+ def execute(merge_request, strategy = nil)
+ strategy ||= preferred_strategy(merge_request)
+ service = get_service_instance(merge_request, strategy)
return :failed unless service&.available_for?(merge_request)
@@ -27,37 +30,47 @@ class AutoMergeService < BaseService
def update(merge_request)
return :failed unless merge_request.auto_merge_enabled?
- get_service_instance(merge_request.auto_merge_strategy).update(merge_request)
+ strategy = merge_request.auto_merge_strategy
+ get_service_instance(merge_request, strategy).update(merge_request)
end
def process(merge_request)
return unless merge_request.auto_merge_enabled?
- get_service_instance(merge_request.auto_merge_strategy).process(merge_request)
+ strategy = merge_request.auto_merge_strategy
+ get_service_instance(merge_request, strategy).process(merge_request)
end
def cancel(merge_request)
return error("Can't cancel the automatic merge", 406) unless merge_request.auto_merge_enabled?
- get_service_instance(merge_request.auto_merge_strategy).cancel(merge_request)
+ strategy = merge_request.auto_merge_strategy
+ get_service_instance(merge_request, strategy).cancel(merge_request)
end
def abort(merge_request, reason)
return error("Can't abort the automatic merge", 406) unless merge_request.auto_merge_enabled?
- get_service_instance(merge_request.auto_merge_strategy).abort(merge_request, reason)
+ strategy = merge_request.auto_merge_strategy
+ get_service_instance(merge_request, strategy).abort(merge_request, reason)
end
def available_strategies(merge_request)
- self.class.all_strategies.select do |strategy|
- get_service_instance(strategy).available_for?(merge_request)
+ self.class.all_strategies_ordered_by_preference.select do |strategy|
+ get_service_instance(merge_request, strategy).available_for?(merge_request)
end
end
+ def preferred_strategy(merge_request)
+ available_strategies(merge_request).first
+ end
+
private
- def get_service_instance(strategy)
- self.class.get_service_class(strategy)&.new(project, current_user, params)
+ def get_service_instance(merge_request, strategy)
+ strong_memoize("service_instance_#{merge_request.id}_#{strategy}") do
+ self.class.get_service_class(strategy)&.new(project, current_user, params)
+ end
end
end
diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb
index bd4ce693085..86b48b5228d 100644
--- a/app/services/clusters/applications/base_service.rb
+++ b/app/services/clusters/applications/base_service.rb
@@ -35,6 +35,18 @@ module Clusters
application.modsecurity_mode = params[:modsecurity_mode] || 0
end
+ if application.has_attribute?(:host)
+ application.host = params[:host]
+ end
+
+ if application.has_attribute?(:protocol)
+ application.protocol = params[:protocol]
+ end
+
+ if application.has_attribute?(:port)
+ application.port = params[:port]
+ end
+
if application.respond_to?(:oauth_application)
application.oauth_application = create_oauth_application(application, request)
end
diff --git a/app/services/concerns/deploy_token_methods.rb b/app/services/concerns/deploy_token_methods.rb
index c875342a07c..f59a50d6878 100644
--- a/app/services/concerns/deploy_token_methods.rb
+++ b/app/services/concerns/deploy_token_methods.rb
@@ -14,4 +14,12 @@ module DeployTokenMethods
deploy_token.destroy
end
+
+ def create_deploy_token_payload_for(deploy_token)
+ if deploy_token.persisted?
+ success(deploy_token: deploy_token, http_status: :created)
+ else
+ error(deploy_token.errors.full_messages.to_sentence, :bad_request, pass_back: { deploy_token: deploy_token })
+ end
+ end
end
diff --git a/app/services/emails/destroy_service.rb b/app/services/emails/destroy_service.rb
index a0b43ad3d08..6e671f52d57 100644
--- a/app/services/emails/destroy_service.rb
+++ b/app/services/emails/destroy_service.rb
@@ -13,7 +13,7 @@ module Emails
user.update_secondary_emails!
end
- result[:status] == 'success'
+ result[:status] == :success
end
end
end
diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb
index da45bcc7eaa..5c1ee981d0c 100644
--- a/app/services/git/branch_push_service.rb
+++ b/app/services/git/branch_push_service.rb
@@ -36,6 +36,8 @@ module Git
# Update merge requests that may be affected by this push. A new branch
# could cause the last commit of a merge request to change.
def enqueue_update_mrs
+ return if params[:merge_request_branches]&.exclude?(branch_name)
+
UpdateMergeRequestsWorker.perform_async(
project.id,
current_user.id,
diff --git a/app/services/git/process_ref_changes_service.rb b/app/services/git/process_ref_changes_service.rb
index 387cd29d69d..6d1ff97016b 100644
--- a/app/services/git/process_ref_changes_service.rb
+++ b/app/services/git/process_ref_changes_service.rb
@@ -42,6 +42,7 @@ module Git
push_service_class = push_service_class_for(ref_type)
create_bulk_push_event = changes.size > Gitlab::CurrentSettings.push_event_activities_limit
+ merge_request_branches = merge_request_branches_for(changes)
changes.each do |change|
push_service_class.new(
@@ -49,6 +50,7 @@ module Git
current_user,
change: change,
push_options: params[:push_options],
+ merge_request_branches: merge_request_branches,
create_pipelines: change[:index] < PIPELINE_PROCESS_LIMIT || Feature.enabled?(:git_push_create_all_pipelines, project),
execute_project_hooks: execute_project_hooks,
create_push_event: !create_bulk_push_event
@@ -71,5 +73,11 @@ module Git
Git::BranchPushService
end
+
+ def merge_request_branches_for(changes)
+ return if Feature.disabled?(:refresh_only_existing_merge_requests_on_push, default_enabled: true)
+
+ @merge_requests_branches ||= MergeRequests::PushedBranchesService.new(project, current_user, changes: changes).execute
+ end
end
end
diff --git a/app/services/groups/deploy_tokens/create_service.rb b/app/services/groups/deploy_tokens/create_service.rb
index 81f761eb61d..aee423659ef 100644
--- a/app/services/groups/deploy_tokens/create_service.rb
+++ b/app/services/groups/deploy_tokens/create_service.rb
@@ -8,11 +8,7 @@ module Groups
def execute
deploy_token = create_deploy_token_for(@group, params)
- if deploy_token.persisted?
- success(deploy_token: deploy_token, http_status: :created)
- else
- error(deploy_token.errors.full_messages.to_sentence, :bad_request)
- end
+ create_deploy_token_payload_for(deploy_token)
end
end
end
diff --git a/app/services/groups/import_export/import_service.rb b/app/services/groups/import_export/import_service.rb
index 548a4a98dc1..f62b9d3c8a6 100644
--- a/app/services/groups/import_export/import_service.rb
+++ b/app/services/groups/import_export/import_service.rb
@@ -33,10 +33,12 @@ module Groups
end
def restorer
- @restorer ||= Gitlab::ImportExport::Group::TreeRestorer.new(user: @current_user,
- shared: @shared,
- group: @group,
- group_hash: nil)
+ @restorer ||= Gitlab::ImportExport::Group::LegacyTreeRestorer.new(
+ user: @current_user,
+ shared: @shared,
+ group: @group,
+ group_hash: nil
+ )
end
def remove_import_file
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index 4e7875e0491..fe3ab884302 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -2,15 +2,6 @@
module Groups
class TransferService < Groups::BaseService
- ERROR_MESSAGES = {
- database_not_supported: s_('TransferGroup|Database is not supported.'),
- namespace_with_same_path: s_('TransferGroup|The parent group already has a subgroup with the same path.'),
- group_is_already_root: s_('TransferGroup|Group is already a root group.'),
- same_parent_as_current: s_('TransferGroup|Group is already associated to the parent group.'),
- invalid_policies: s_("TransferGroup|You don't have enough permissions."),
- group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.')
- }.freeze
-
TransferError = Class.new(StandardError)
attr_reader :error, :new_parent_group
@@ -124,7 +115,18 @@ module Groups
end
def raise_transfer_error(message)
- raise TransferError, ERROR_MESSAGES[message]
+ raise TransferError, localized_error_messages[message]
+ end
+
+ def localized_error_messages
+ {
+ database_not_supported: s_('TransferGroup|Database is not supported.'),
+ namespace_with_same_path: s_('TransferGroup|The parent group already has a subgroup with the same path.'),
+ group_is_already_root: s_('TransferGroup|Group is already a root group.'),
+ same_parent_as_current: s_('TransferGroup|Group is already associated to the parent group.'),
+ invalid_policies: s_("TransferGroup|You don't have enough permissions."),
+ group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.')
+ }.freeze
end
end
end
diff --git a/app/services/issues/export_csv_service.rb b/app/services/issues/export_csv_service.rb
new file mode 100644
index 00000000000..1dcdfb9faea
--- /dev/null
+++ b/app/services/issues/export_csv_service.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+module Issues
+ class ExportCsvService
+ include Gitlab::Routing.url_helpers
+ include GitlabRoutingHelper
+
+ # Target attachment size before base64 encoding
+ TARGET_FILESIZE = 15000000
+
+ attr_reader :project
+
+ def initialize(issues_relation, project)
+ @issues = issues_relation
+ @labels = @issues.labels_hash
+ @project = project
+ end
+
+ def csv_data
+ csv_builder.render(TARGET_FILESIZE)
+ end
+
+ def email(user)
+ Notify.issues_csv_email(user, project, csv_data, csv_builder.status).deliver_now
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def csv_builder
+ @csv_builder ||=
+ CsvBuilder.new(@issues.preload(associations_to_preload), header_to_value_hash)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ def associations_to_preload
+ %i(author assignees timelogs)
+ end
+
+ def header_to_value_hash
+ {
+ 'Issue ID' => 'iid',
+ 'URL' => -> (issue) { issue_url(issue) },
+ 'Title' => 'title',
+ 'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' },
+ 'Description' => 'description',
+ 'Author' => 'author_name',
+ 'Author Username' => -> (issue) { issue.author&.username },
+ 'Assignee' => -> (issue) { issue.assignees.map(&:name).join(', ') },
+ 'Assignee Username' => -> (issue) { issue.assignees.map(&:username).join(', ') },
+ 'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' },
+ 'Locked' => -> (issue) { issue.discussion_locked? ? 'Yes' : 'No' },
+ 'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) },
+ 'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) },
+ 'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_s(:csv) },
+ 'Closed At (UTC)' => -> (issue) { issue.closed_at&.to_s(:csv) },
+ 'Milestone' => -> (issue) { issue.milestone&.title },
+ 'Weight' => -> (issue) { issue.weight },
+ 'Labels' => -> (issue) { issue_labels(issue) },
+ 'Time Estimate' => ->(issue) { issue.time_estimate.to_s(:csv) },
+ 'Time Spent' => -> (issue) { issue_time_spent(issue) }
+ }
+ end
+
+ def issue_labels(issue)
+ @labels[issue.id].sort.join(',').presence
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def issue_time_spent(issue)
+ issue.timelogs.map(&:time_spent).sum
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
+
+Issues::ExportCsvService.prepend_if_ee('EE::Issues::ExportCsvService')
diff --git a/app/services/jira_import/start_import_service.rb b/app/services/jira_import/start_import_service.rb
index e8d9e6734bd..de4e490281f 100644
--- a/app/services/jira_import/start_import_service.rb
+++ b/app/services/jira_import/start_import_service.rb
@@ -62,12 +62,12 @@ module JiraImport
end
def validate
- return build_error_response(_('Jira import feature is disabled.')) unless project.jira_issues_import_feature_flag_enabled?
- return build_error_response(_('You do not have permissions to run the import.')) unless user.can?(:admin_project, project)
- return build_error_response(_('Cannot import because issues are not available in this project.')) unless project.feature_available?(:issues, user)
- return build_error_response(_('Jira integration not configured.')) unless project.jira_service&.active?
+ project.validate_jira_import_settings!(user: user)
+
return build_error_response(_('Unable to find Jira project to import data from.')) if jira_project_key.blank?
return build_error_response(_('Jira import is already running.')) if import_in_progress?
+ rescue Projects::ImportService::Error => e
+ build_error_response(e.message)
end
def build_error_response(message)
diff --git a/app/services/merge_requests/merge_orchestration_service.rb b/app/services/merge_requests/merge_orchestration_service.rb
new file mode 100644
index 00000000000..24341ef1145
--- /dev/null
+++ b/app/services/merge_requests/merge_orchestration_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class MergeOrchestrationService < ::BaseService
+ def execute(merge_request)
+ return unless can_merge?(merge_request)
+
+ merge_request.update(merge_error: nil)
+
+ if can_merge_automatically?(merge_request)
+ auto_merge_service.execute(merge_request)
+ else
+ merge_request.merge_async(current_user.id, params)
+ end
+ end
+
+ def can_merge?(merge_request)
+ can_merge_automatically?(merge_request) || can_merge_immediately?(merge_request)
+ end
+
+ def preferred_auto_merge_strategy(merge_request)
+ auto_merge_service.preferred_strategy(merge_request)
+ end
+
+ private
+
+ def can_merge_immediately?(merge_request)
+ merge_request.can_be_merged_by?(current_user) &&
+ merge_request.mergeable_state?
+ end
+
+ def can_merge_automatically?(merge_request)
+ auto_merge_service.available_strategies(merge_request).any?
+ end
+
+ def auto_merge_service
+ @auto_merge_service ||= AutoMergeService.new(project, current_user, params)
+ end
+ end
+end
diff --git a/app/services/merge_requests/pushed_branches_service.rb b/app/services/merge_requests/pushed_branches_service.rb
new file mode 100644
index 00000000000..afcf0f7678a
--- /dev/null
+++ b/app/services/merge_requests/pushed_branches_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class PushedBranchesService < MergeRequests::BaseService
+ include ::Gitlab::Utils::StrongMemoize
+
+ # Skip moving this logic into models since it's too specific
+ # rubocop: disable CodeReuse/ActiveRecord
+ def execute
+ return [] if branch_names.blank?
+
+ source_branches = project.source_of_merge_requests.opened
+ .from_source_branches(branch_names).pluck(:source_branch)
+
+ target_branches = project.merge_requests.opened
+ .by_target_branch(branch_names).distinct.pluck(:target_branch)
+
+ source_branches.concat(target_branches).to_set
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ def branch_names
+ strong_memoize(:branch_names) do
+ params[:changes].map do |change|
+ Gitlab::Git.branch_name(change[:ref])
+ end.compact
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index 1516e33a7c6..2d33e87bf4b 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -79,14 +79,21 @@ module MergeRequests
def merge_from_quick_action(merge_request)
last_diff_sha = params.delete(:merge)
- return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha)
- merge_request.update(merge_error: nil)
-
- if merge_request.head_pipeline_active?
- AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS)
+ if Feature.enabled?(:merge_orchestration_service, merge_request.project, default_enabled: true)
+ MergeRequests::MergeOrchestrationService
+ .new(project, current_user, { sha: last_diff_sha })
+ .execute(merge_request)
else
- merge_request.merge_async(current_user.id, { sha: last_diff_sha })
+ return unless merge_request.mergeable_with_quick_action?(current_user, last_diff_sha: last_diff_sha)
+
+ merge_request.update(merge_error: nil)
+
+ if merge_request.head_pipeline_active?
+ AutoMergeService.new(project, current_user, { sha: last_diff_sha }).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS)
+ else
+ merge_request.merge_async(current_user.id, { sha: last_diff_sha })
+ end
end
end
diff --git a/app/services/metrics/dashboard/transient_embed_service.rb b/app/services/metrics/dashboard/transient_embed_service.rb
index 035707dceb9..ce81f337e47 100644
--- a/app/services/metrics/dashboard/transient_embed_service.rb
+++ b/app/services/metrics/dashboard/transient_embed_service.rb
@@ -30,6 +30,11 @@ module Metrics
def sequence
[STAGES::EndpointInserter]
end
+
+ override :identifiers
+ def identifiers
+ Digest::SHA256.hexdigest(params[:embed_json])
+ end
end
end
end
diff --git a/app/services/personal_access_tokens/create_service.rb b/app/services/personal_access_tokens/create_service.rb
new file mode 100644
index 00000000000..ff9bb7d6802
--- /dev/null
+++ b/app/services/personal_access_tokens/create_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module PersonalAccessTokens
+ class CreateService < BaseService
+ def initialize(current_user, params = {})
+ @current_user = current_user
+ @params = params.dup
+ end
+
+ def execute
+ personal_access_token = current_user.personal_access_tokens.create(params.slice(*allowed_params))
+
+ if personal_access_token.persisted?
+ ServiceResponse.success(payload: { personal_access_token: personal_access_token })
+ else
+ ServiceResponse.error(message: personal_access_token.errors.full_messages.to_sentence)
+ end
+ end
+
+ private
+
+ def allowed_params
+ [
+ :name,
+ :impersonation,
+ :scopes,
+ :expires_at
+ ]
+ end
+ end
+end
diff --git a/app/services/pod_logs/base_service.rb b/app/services/pod_logs/base_service.rb
index 8cc8fb913a2..2451ab8e0ce 100644
--- a/app/services/pod_logs/base_service.rb
+++ b/app/services/pod_logs/base_service.rb
@@ -62,13 +62,11 @@ module PodLogs
end
def get_raw_pods(result)
- result[:raw_pods] = cluster.kubeclient.get_pods(namespace: namespace)
-
- success(result)
+ raise NotImplementedError
end
def get_pod_names(result)
- result[:pods] = result[:raw_pods].map(&:metadata).map(&:name)
+ result[:pods] = result[:raw_pods].map { |p| p[:name] }
success(result)
end
diff --git a/app/services/pod_logs/elasticsearch_service.rb b/app/services/pod_logs/elasticsearch_service.rb
index 0a5185999ab..aac0fa424ca 100644
--- a/app/services/pod_logs/elasticsearch_service.rb
+++ b/app/services/pod_logs/elasticsearch_service.rb
@@ -23,6 +23,23 @@ module PodLogs
super + %i(cursor)
end
+ def get_raw_pods(result)
+ client = cluster&.application_elastic_stack&.elasticsearch_client
+ return error(_('Unable to connect to Elasticsearch')) unless client
+
+ result[:raw_pods] = ::Gitlab::Elasticsearch::Logs::Pods.new(client).pods(namespace)
+
+ success(result)
+ rescue Elasticsearch::Transport::Transport::ServerError => e
+ ::Gitlab::ErrorTracking.track_exception(e)
+
+ error(_('Elasticsearch returned status code: %{status_code}') % {
+ # ServerError is the parent class of exceptions named after HTTP status codes, eg: "Elasticsearch::Transport::Transport::Errors::NotFound"
+ # there is no method on the exception other than the class name to determine the type of error encountered.
+ status_code: e.class.name.split('::').last
+ })
+ end
+
def check_times(result)
result[:start_time] = params['start_time'] if params.key?('start_time') && Time.iso8601(params['start_time'])
result[:end_time] = params['end_time'] if params.key?('end_time') && Time.iso8601(params['end_time'])
@@ -48,7 +65,7 @@ module PodLogs
client = cluster&.application_elastic_stack&.elasticsearch_client
return error(_('Unable to connect to Elasticsearch')) unless client
- response = ::Gitlab::Elasticsearch::Logs.new(client).pod_logs(
+ response = ::Gitlab::Elasticsearch::Logs::Lines.new(client).pod_logs(
namespace,
pod_name: result[:pod_name],
container_name: result[:container_name],
@@ -69,7 +86,7 @@ module PodLogs
# there is no method on the exception other than the class name to determine the type of error encountered.
status_code: e.class.name.split('::').last
})
- rescue ::Gitlab::Elasticsearch::Logs::InvalidCursor
+ rescue ::Gitlab::Elasticsearch::Logs::Lines::InvalidCursor
error(_('Invalid cursor value provided'))
end
end
diff --git a/app/services/pod_logs/kubernetes_service.rb b/app/services/pod_logs/kubernetes_service.rb
index 31e26912c73..0a8072a9037 100644
--- a/app/services/pod_logs/kubernetes_service.rb
+++ b/app/services/pod_logs/kubernetes_service.rb
@@ -21,6 +21,17 @@ module PodLogs
private
+ def get_raw_pods(result)
+ result[:raw_pods] = cluster.kubeclient.get_pods(namespace: namespace).map do |pod|
+ {
+ name: pod.metadata.name,
+ container_names: pod.spec.containers.map(&:name)
+ }
+ end
+
+ success(result)
+ end
+
def check_pod_name(result)
# If pod_name is not received as parameter, get the pod logs of the first
# pod of this namespace.
@@ -43,11 +54,11 @@ module PodLogs
end
def check_container_name(result)
- pod_details = result[:raw_pods].find { |p| p.metadata.name == result[:pod_name] }
- containers = pod_details.spec.containers.map(&:name)
+ pod_details = result[:raw_pods].find { |p| p[:name] == result[:pod_name] }
+ container_names = pod_details[:container_names]
# select first container if not specified
- result[:container_name] ||= containers.first
+ result[:container_name] ||= container_names.first
unless result[:container_name]
return error(_('No containers available'))
@@ -58,7 +69,7 @@ module PodLogs
' %{max_length} chars' % { max_length: K8S_NAME_MAX_LENGTH }))
end
- unless containers.include?(result[:container_name])
+ unless container_names.include?(result[:container_name])
return error(_('Container does not exist'))
end
diff --git a/app/services/projects/deploy_tokens/create_service.rb b/app/services/projects/deploy_tokens/create_service.rb
index 2e71650b066..592198ef241 100644
--- a/app/services/projects/deploy_tokens/create_service.rb
+++ b/app/services/projects/deploy_tokens/create_service.rb
@@ -8,11 +8,7 @@ module Projects
def execute
deploy_token = create_deploy_token_for(@project, params)
- if deploy_token.persisted?
- success(deploy_token: deploy_token, http_status: :created)
- else
- error(deploy_token.errors.full_messages.to_sentence, :bad_request)
- end
+ create_deploy_token_payload_for(deploy_token)
end
end
end
diff --git a/app/services/resources/create_access_token_service.rb b/app/services/resources/create_access_token_service.rb
new file mode 100644
index 00000000000..fd3c8d78e58
--- /dev/null
+++ b/app/services/resources/create_access_token_service.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+module Resources
+ class CreateAccessTokenService < BaseService
+ attr_accessor :resource_type, :resource
+
+ def initialize(resource_type, resource, user, params = {})
+ @resource_type = resource_type
+ @resource = resource
+ @current_user = user
+ @params = params.dup
+ end
+
+ def execute
+ return unless feature_enabled?
+ return error("User does not have permission to create #{resource_type} Access Token") unless has_permission_to_create?
+
+ # We skip authorization by default, since the user creating the bot is not an admin
+ # and project/group bot users are not created via sign-up
+ user = create_user
+
+ return error(user.errors.full_messages.to_sentence) unless user.persisted?
+ return error("Failed to provide maintainer access") unless provision_access(resource, user)
+
+ token_response = create_personal_access_token(user)
+
+ if token_response.success?
+ success(token_response.payload[:personal_access_token])
+ else
+ error(token_response.message)
+ end
+ end
+
+ private
+
+ def feature_enabled?
+ ::Feature.enabled?(:resource_access_token, resource)
+ end
+
+ def has_permission_to_create?
+ case resource_type
+ when 'project'
+ can?(current_user, :admin_project, resource)
+ when 'group'
+ can?(current_user, :admin_group, resource)
+ else
+ false
+ end
+ end
+
+ def create_user
+ Users::CreateService.new(current_user, default_user_params).execute(skip_authorization: true)
+ end
+
+ def default_user_params
+ {
+ name: params[:name] || "#{resource.name.to_s.humanize} bot",
+ email: generate_email,
+ username: generate_username,
+ user_type: "#{resource_type}_bot".to_sym
+ }
+ end
+
+ def generate_username
+ base_username = "#{resource_type}_#{resource.id}_bot"
+
+ uniquify.string(base_username) { |s| User.find_by_username(s) }
+ end
+
+ def generate_email
+ email_pattern = "#{resource_type}#{resource.id}_bot%s@example.com"
+
+ uniquify.string(-> (n) { Kernel.sprintf(email_pattern, n) }) do |s|
+ User.find_by_email(s)
+ end
+ end
+
+ def uniquify
+ Uniquify.new
+ end
+
+ def create_personal_access_token(user)
+ PersonalAccessTokens::CreateService.new(user, personal_access_token_params).execute
+ end
+
+ def personal_access_token_params
+ {
+ name: "#{resource_type}_bot",
+ impersonation: false,
+ scopes: params[:scopes] || default_scopes,
+ expires_at: params[:expires_at] || nil
+ }
+ end
+
+ def default_scopes
+ Gitlab::Auth::API_SCOPES + Gitlab::Auth::REPOSITORY_SCOPES + Gitlab::Auth.registry_scopes - [:read_user]
+ end
+
+ def provision_access(resource, user)
+ resource.add_maintainer(user)
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def success(access_token)
+ ServiceResponse.success(payload: { access_token: access_token })
+ end
+ end
+end
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
index 0b74bd77e28..155013db344 100644
--- a/app/services/snippets/create_service.rb
+++ b/app/services/snippets/create_service.rb
@@ -38,9 +38,7 @@ module Snippets
private
def save_and_commit
- snippet_saved = @snippet.with_transaction_returning_status do
- @snippet.save && @snippet.store_mentions!
- end
+ snippet_saved = @snippet.save
if snippet_saved && Feature.enabled?(:version_snippets, current_user)
create_repository
diff --git a/app/services/terraform/remote_state_handler.rb b/app/services/terraform/remote_state_handler.rb
new file mode 100644
index 00000000000..5bb6f6a1dee
--- /dev/null
+++ b/app/services/terraform/remote_state_handler.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+module Terraform
+ class RemoteStateHandler < BaseService
+ include Gitlab::OptimisticLocking
+
+ StateLockedError = Class.new(StandardError)
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def find_with_lock
+ raise ArgumentError unless params[:name].present?
+
+ state = Terraform::State.find_by(project: project, name: params[:name])
+ raise ActiveRecord::RecordNotFound.new("Couldn't find state") unless state
+
+ retry_optimistic_lock(state) { |state| yield state } if state && block_given?
+ state
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def create_or_find!
+ raise ArgumentError unless params[:name].present?
+
+ Terraform::State.create_or_find_by(project: project, name: params[:name])
+ end
+
+ def handle_with_lock
+ retrieve_with_lock do |state|
+ raise StateLockedError unless lock_matches?(state)
+
+ yield state if block_given?
+
+ state.save! unless state.destroyed?
+ end
+ end
+
+ def lock!
+ raise ArgumentError if params[:lock_id].blank?
+
+ retrieve_with_lock do |state|
+ raise StateLockedError if state.locked?
+
+ state.lock_xid = params[:lock_id]
+ state.locked_by_user = current_user
+ state.locked_at = Time.now
+
+ state.save!
+ end
+ end
+
+ def unlock!
+ retrieve_with_lock do |state|
+ # force-unlock does not pass ID, so we ignore it if it is missing
+ raise StateLockedError unless params[:lock_id].nil? || lock_matches?(state)
+
+ state.lock_xid = nil
+ state.locked_by_user = nil
+ state.locked_at = nil
+
+ state.save!
+ end
+ end
+
+ private
+
+ def retrieve_with_lock
+ create_or_find!.tap { |state| retry_optimistic_lock(state) { |state| yield state } }
+ end
+
+ def lock_matches?(state)
+ return true if state.lock_xid.nil? && params[:lock_id].nil?
+
+ ActiveSupport::SecurityUtils
+ .secure_compare(state.lock_xid.to_s, params[:lock_id].to_s)
+ end
+ end
+end
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index 6f9f307c322..3938d675596 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -81,7 +81,8 @@ module Users
:private_profile,
:organization,
:location,
- :public_email
+ :public_email,
+ :user_type
]
end
@@ -95,7 +96,8 @@ module Users
:first_name,
:last_name,
:password,
- :username
+ :username,
+ :user_type
]
end
@@ -127,6 +129,8 @@ module Users
user_params[:external] = user_external?
end
+ user_params.delete(:user_type) unless project_bot?(user_params[:user_type])
+
user_params
end
@@ -137,6 +141,10 @@ module Users
def user_external?
user_default_internal_regex_instance.match(params[:email]).nil?
end
+
+ def project_bot?(user_type)
+ user_type&.to_sym == :project_bot
+ end
end
end
diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb
index 427314a87bb..967fcdc704e 100644
--- a/app/uploaders/records_uploads.rb
+++ b/app/uploaders/records_uploads.rb
@@ -56,31 +56,10 @@ module RecordsUploads
size: file.size,
path: upload_path,
model: model,
- mount_point: mounted_as,
- store: initial_store
+ mount_point: mounted_as
)
end
- def initial_store
- if immediately_remote_stored?
- ::ObjectStorage::Store::REMOTE
- else
- ::ObjectStorage::Store::LOCAL
- end
- end
-
- def immediately_remote_stored?
- object_storage_available? && direct_upload_enabled?
- end
-
- def object_storage_available?
- self.class.ancestors.include?(ObjectStorage::Concern)
- end
-
- def direct_upload_enabled?
- self.class.object_store_enabled? && self.class.direct_upload_enabled?
- end
-
# Before removing an attachment, destroy any Upload records at the same path
#
# Called `before :remove`
diff --git a/app/uploaders/terraform/state_uploader.rb b/app/uploaders/terraform/state_uploader.rb
index 9c5ae8a8bdc..2306313fc82 100644
--- a/app/uploaders/terraform/state_uploader.rb
+++ b/app/uploaders/terraform/state_uploader.rb
@@ -12,7 +12,7 @@ module Terraform
encrypt(key: :key)
def filename
- "#{model.id}.tfstate"
+ "#{model.uuid}.tfstate"
end
def store_dir
diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml
index f860b7a61a2..0120d4038b9 100644
--- a/app/views/admin/runners/show.html.haml
+++ b/app/views/admin/runners/show.html.haml
@@ -28,7 +28,7 @@
%hr
.append-bottom-20
- = render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner)
+ = render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner), in_gitlab_com_admin_context: Gitlab.com?
.row
.col-md-6
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index 3fa957f38a0..4d8df4cc12a 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -5,7 +5,7 @@
- link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protected-variables') }
= s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
-- if Feature.enabled?(:new_variables_ui, @project || @group)
+- if Feature.enabled?(:new_variables_ui, @project || @group, default_enabled: true)
- is_group = !@group.nil?
#js-ci-project-variables{ data: { endpoint: save_endpoint, project_id: @project&.id || '', group: is_group.to_s, maskable_regex: ci_variable_maskable_regex} }
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 06e3bca99a1..80a14412968 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -5,7 +5,8 @@
.mobile-overlay
.alert-wrapper
= render 'shared/outdated_browser'
- = render_if_exists "layouts/header/ee_license_banner"
+ - if Feature.enabled?(:subscribable_banner_license)
+ = render_if_exists "layouts/header/ee_subscribable_banner"
= render "layouts/broadcast"
= render "layouts/header/read_only_banner"
= render "layouts/nav/classification_level_banner"
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index c6299f244ec..410b120396d 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -26,7 +26,7 @@
- if current_user_menu?(:settings)
%li
= link_to s_("CurrentUser|Settings"), profile_path, data: { qa_selector: 'settings_link' }
- = render_if_exists 'layouts/header/buy_ci_minutes'
+ = render_if_exists 'layouts/header/buy_ci_minutes', project: @project, namespace: @group
- if current_user_menu?(:help)
%li.divider.d-md-none
diff --git a/app/views/notify/issues_csv_email.html.haml b/app/views/notify/issues_csv_email.html.haml
new file mode 100644
index 00000000000..b777ca1e57d
--- /dev/null
+++ b/app/views/notify/issues_csv_email.html.haml
@@ -0,0 +1,9 @@
+-# haml-lint:disable NoPlainNodes
+%p{ style: 'font-size:18px; text-align:center; line-height:30px;' }
+ Your CSV export of #{ pluralize(@written_count, 'issue') } from project
+ %a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;" }
+ = @project.full_name
+ has been added to this email as an attachment.
+ - if @truncated
+ %p
+ This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 15MB. #{ @written_count } of #{ @issues_count } issues have been included. Consider re-exporting with a narrower selection of issues.
diff --git a/app/views/notify/issues_csv_email.text.erb b/app/views/notify/issues_csv_email.text.erb
new file mode 100644
index 00000000000..5d4128e3ae9
--- /dev/null
+++ b/app/views/notify/issues_csv_email.text.erb
@@ -0,0 +1,5 @@
+Your CSV export of <%= pluralize(@written_count, 'issue') %> from project <%= @project.full_name %> (<%= project_url(@project) %>) has been added to this email as an attachment.
+
+<% if @truncated %>
+This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 15MB. <%= @written_count %> of <%= @issues_count %> issues have been included. Consider re-exporting with a narrower selection of issues.
+<% end %>
diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml
index f9222387e97..8217608db4e 100644
--- a/app/views/projects/_flash_messages.html.haml
+++ b/app/views/projects/_flash_messages.html.haml
@@ -8,4 +8,6 @@
- unless project.empty_repo?
= render 'shared/auto_devops_implicitly_enabled_banner', project: project
= render_if_exists 'projects/above_size_limit_warning', project: project
+ - if Feature.enabled?(:subscribable_banner_subscription)
+ = render_if_exists "layouts/header/ee_subscribable_banner", subscription: true
= render_if_exists 'shared/shared_runners_minutes_limit', project: project, classes: [container_class, ("limit-container-width" unless fluid_layout)]
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index d9887cb470a..be58ecb3572 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -14,6 +14,7 @@
= @project.name
%span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
= visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'})
+ = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project
.home-panel-metadata.d-flex.flex-wrap.text-secondary
- if can?(current_user, :read_project, @project)
%span.text-secondary
diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml
index ec05ff50f25..2e5953bf0a6 100644
--- a/app/views/projects/commits/_commits.html.haml
+++ b/app/views/projects/commits/_commits.html.haml
@@ -4,11 +4,12 @@
- commits = @commits
- hidden = @hidden_commit_count
+- commits_count = @commits.size
- commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, commits|
%li.commit-header.js-commit-header{ data: { day: day } }
%span.day= l(day, format: '%d %b, %Y')
- %span.commits-count= n_("%d commit", "%d commits", commits.count) % commits.count
+ %span.commits-count= n_("%d commit", "%d commits", commits_count) % commits_count
%li.commits-row{ data: { day: day } }
%ul.content-list.commit-list.flex-list
@@ -17,3 +18,9 @@
- if hidden > 0
%li.alert.alert-warning
= n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden)
+
+- if commits_count == 0
+ .mt-4.text-center
+ .bold
+ = _('Your search didn\'t match any commits.')
+ = _('Try changing or removing filters.')
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index b0d9dfb0d37..da20fee227a 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -10,27 +10,25 @@
.card
.card-header
{{ __('Recent Project Activity') }}
- .content-block
- .container-fluid
- .row
- .col-12.column{ "v-for" => "item in state.summary", ":class" => "summaryTableColumnClass" }
- %h3.header {{ item.value }}
- %p.text {{ item.title }}
- .col-12.column{ ":class" => "summaryTableColumnClass" }
- .dropdown.inline.js-ca-dropdown
- %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" }
- %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }}
- %i.fa.fa-chevron-down
- %ul.dropdown-menu.dropdown-menu-right
- %li
- %a{ "href" => "#", "data-value" => "7" }
- {{ n__('Last %d day', 'Last %d days', 7) }}
- %li
- %a{ "href" => "#", "data-value" => "30" }
- {{ n__('Last %d day', 'Last %d days', 30) }}
- %li
- %a{ "href" => "#", "data-value" => "90" }
- {{ n__('Last %d day', 'Last %d days', 90) }}
+ .d-flex.justify-content-between
+ .flex-grow.text-center{ "v-for" => "item in state.summary" }
+ %h3.header {{ item.value }}
+ %p.text {{ item.title }}
+ .flex-grow.align-self-center.text-center
+ .dropdown.inline.js-ca-dropdown
+ %button.dropdown-menu-toggle{ "data-toggle" => "dropdown", :type => "button" }
+ %span.dropdown-label {{ n__('Last %d day', 'Last %d days', 30) }}
+ %i.fa.fa-chevron-down
+ %ul.dropdown-menu.dropdown-menu-right
+ %li
+ %a{ "href" => "#", "data-value" => "7" }
+ {{ n__('Last %d day', 'Last %d days', 7) }}
+ %li
+ %a{ "href" => "#", "data-value" => "30" }
+ {{ n__('Last %d day', 'Last %d days', 30) }}
+ %li
+ %a{ "href" => "#", "data-value" => "90" }
+ {{ n__('Last %d day', 'Last %d days', 90) }}
.stage-panel-container
.card.stage-panel
.card-header.border-bottom-0
diff --git a/app/views/projects/import/jira/show.html.haml b/app/views/projects/import/jira/show.html.haml
index 6003f33f0ba..4106bcc2e5a 100644
--- a/app/views/projects/import/jira/show.html.haml
+++ b/app/views/projects/import/jira/show.html.haml
@@ -1,6 +1,9 @@
-- if Feature.enabled?(:jira_issue_import_vue, @project)
+- if Feature.enabled?(:jira_issue_import_vue, @project, default_enabled: true)
.js-jira-import-root{ data: { project_path: @project.full_path,
- is_jira_configured: @is_jira_configured.to_s,
+ issues_path: project_issues_path(@project),
+ is_jira_configured: @project.jira_service.present?.to_s,
+ jira_projects: @jira_projects.to_json,
+ in_progress_illustration: image_path('illustrations/export-import.svg'),
setup_illustration: image_path('illustrations/manual_action.svg') } }
- else
- title = _('Jira Issue Import')
diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml
index c347b8d2c9c..71c9bb36936 100644
--- a/app/views/projects/issues/_nav_btns.html.haml
+++ b/app/views/projects/issues/_nav_btns.html.haml
@@ -8,7 +8,7 @@
.btn-group
- if show_export_button
- = render_if_exists 'projects/issues/export_csv/button'
+ = render 'projects/issues/export_csv/button'
- if show_import_button
= render 'projects/issues/import_csv/button'
@@ -23,7 +23,7 @@
id: "new_issue_link"
- if show_export_button
- = render_if_exists 'projects/issues/export_csv/modal'
+ = render 'projects/issues/export_csv/modal'
- if show_import_button
= render 'projects/issues/import_csv/modal'
diff --git a/app/views/projects/issues/export_csv/_button.html.haml b/app/views/projects/issues/export_csv/_button.html.haml
new file mode 100644
index 00000000000..ef3fb438641
--- /dev/null
+++ b/app/views/projects/issues/export_csv/_button.html.haml
@@ -0,0 +1,4 @@
+- if current_user
+ %button.csv_download_link.btn.has-tooltip{ title: _('Export as CSV'),
+ data: { toggle: 'modal', target: '.issues-export-modal', qa_selector: 'export_as_csv_button' } }
+ = sprite_icon('export')
diff --git a/app/views/projects/issues/export_csv/_modal.html.haml b/app/views/projects/issues/export_csv/_modal.html.haml
new file mode 100644
index 00000000000..af3a087ca59
--- /dev/null
+++ b/app/views/projects/issues/export_csv/_modal.html.haml
@@ -0,0 +1,22 @@
+-# haml-lint:disable NoPlainNodes
+- if current_user
+ .issues-export-modal.modal
+ .modal-dialog
+ .modal-content{ data: { qa_selector: 'export_issues_modal' } }
+ .modal-header
+ %h3
+ = _('Export issues')
+ .svg-content.import-export-svg-container
+ = image_tag 'illustrations/export-import.svg', alt: _('Import/Export illustration'), class: 'illustration'
+ %a.close{ href: '#', 'data-dismiss' => 'modal' }
+ = sprite_icon('close', size: 16, css_class: 'gl-icon')
+ .modal-body
+ .modal-subheader
+ = icon('check', { class: 'checkmark' })
+ %strong.prepend-left-10
+ - issues_count = issuables_count_for_state(:issues, params[:state])
+ = n_('%d issue selected', '%d issues selected', issues_count) % issues_count
+ .modal-text
+ = _('The CSV export will be created in the background. Once finished, it will be sent to <strong>%{email}</strong> in an attachment.').html_safe % { email: @current_user.notification_email }
+ .modal-footer
+ = link_to _('Export issues'), export_csv_project_issues_path(@project, request.query_parameters), method: :post, class: 'btn btn-success float-left', title: _('Export issues'), data: { track_label: "export_issues_csv", track_event: "click_button", track_value: "", qa_selector: "export_issues_button" }
diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml
index bd9defe5f74..0dbd6a48ec5 100644
--- a/app/views/projects/services/_form.html.haml
+++ b/app/views/projects/services/_form.html.haml
@@ -12,11 +12,10 @@
.col-lg-9
= form_for(@service, as: :service, url: scoped_integration_path(@service), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'can-test' => @service.can_test?, 'test-url' => test_project_service_path(@project, @service) } }) do |form|
= render 'shared/service_settings', form: form, service: @service
- - if @service.editable?
- .footer-block.row-content-block
- = service_save_button(@service)
- &nbsp;
- = link_to _('Cancel'), project_settings_integrations_path(@project), class: 'btn btn-cancel'
+ .footer-block.row-content-block
+ = service_save_button(@service)
+ &nbsp;
+ = link_to _('Cancel'), project_settings_integrations_path(@project), class: 'btn btn-cancel'
- if lookup_context.template_exists?('show', "projects/services/#{@service.to_param}", true)
%hr
diff --git a/app/views/projects/services/prometheus/_help.html.haml b/app/views/projects/services/prometheus/_help.html.haml
index 35d655e4b32..1b5b794a7aa 100644
--- a/app/views/projects/services/prometheus/_help.html.haml
+++ b/app/views/projects/services/prometheus/_help.html.haml
@@ -3,7 +3,5 @@
%h4.append-bottom-default
= s_('PrometheusService|Manual configuration')
-
-- unless @service.editable?
- .info-well
- = s_('PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters')
+%p
+ = s_('PrometheusService|Select the Active checkbox to override the Auto Configuration with custom settings. If unchecked, Auto Configuration settings are used.')
diff --git a/app/views/projects/settings/operations/_prometheus.html.haml b/app/views/projects/settings/operations/_prometheus.html.haml
index 3d7a6b021a8..b0fa750e131 100644
--- a/app/views/projects/settings/operations/_prometheus.html.haml
+++ b/app/views/projects/settings/operations/_prometheus.html.haml
@@ -13,7 +13,5 @@
%b.append-bottom-default
= s_('PrometheusService|Manual configuration')
-
- - unless service.editable?
- .info-well
- = s_('PrometheusService|To enable manual configuration, uninstall Prometheus from your clusters')
+ %p
+ = s_('PrometheusService|Select the Active checkbox to override the Auto Configuration with custom settings. If unchecked, Auto Configuration settings are used.')
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index d29ba3eedc6..3d61943193f 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -54,6 +54,10 @@
.metadata-info.prepend-top-8
%span.user-access-role.d-block= Gitlab::Access.human_access(access)
+ - if !explore_projects_tab?
+ .metadata-info.prepend-top-8
+ = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project
+
- if show_last_commit_as_description
.description.d-none.d-sm-block.append-right-default
= link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message")
diff --git a/app/views/shared/runners/_form.html.haml b/app/views/shared/runners/_form.html.haml
index 24b4eae0c58..675a8f922c4 100644
--- a/app/views/shared/runners/_form.html.haml
+++ b/app/views/shared/runners/_form.html.haml
@@ -47,5 +47,16 @@
.col-sm-10
= f.text_field :tag_list, value: runner.tag_list.sort.join(', '), class: 'form-control'
.form-text.text-muted= _('You can set up jobs to only use Runners with specific tags. Separate tags with commas.')
+ - if local_assigns[:in_gitlab_com_admin_context]
+ .form-group.row
+ = label_tag :public_projects_minutes_cost_factor, class: 'col-form-label col-sm-2' do
+ = _('Public projects Minutes cost factor')
+ .col-sm-10
+ = f.text_field :public_projects_minutes_cost_factor, class: 'form-control'
+ .form-group.row
+ = label_tag :private_projects_minutes_cost_factor, class: 'col-form-label col-sm-2' do
+ = _('Private projects Minutes cost factor')
+ .col-sm-10
+ = f.text_field :private_projects_minutes_cost_factor, class: 'form-control'
.form-actions
= f.submit _('Save changes'), class: 'btn btn-success'
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index 5ba6d52fefe..396b6e56ea9 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -1,54 +1,58 @@
-- content_for :page_specific_javascripts do
- = page_specific_javascript_tag('lib/ace.js')
-
-.snippet-form-holder
- = form_for @snippet, url: url,
- html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" },
- data: { "snippet-type": @snippet.project_id ? 'project' : 'personal'} do |f|
- = form_errors(@snippet)
-
- .form-group
- = f.label :title, class: 'label-bold'
- = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true
-
- .form-group.js-description-input
- - description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...')
- - is_expanded = @snippet.description && !@snippet.description.empty?
- = f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold'
- .js-collapsible-input
- .js-collapsed{ class: ('d-none' if is_expanded) }
- = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' }
- .js-expanded{ class: ('d-none' if !is_expanded) }
- = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
- = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'description_field'
- = render 'shared/notes/hints'
-
- .form-group.file-editor
- = f.label :file_name, s_('Snippets|File')
- .file-holder.snippet
- .js-file-title.file-title-flex-parent
- = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name qa-snippet-file-name'
- .file-content.code
- %pre#editor{ data: { 'editor-loading': true } }= @snippet.content
- = f.hidden_field :content, class: 'snippet-file-content'
-
- .form-group
- .font-weight-bold
- = _('Visibility level')
- = link_to icon('question-circle'), help_page_path("public_access/public_access"), target: '_blank'
- = render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet, with_label: false
-
- - if params[:files]
- - params[:files].each_with_index do |file, index|
- = hidden_field_tag "files[]", file, id: "files_#{index}"
-
- .form-actions
- - if @snippet.new_record?
- = f.submit 'Create snippet', class: "btn-success btn qa-create-snippet-button"
- - else
- = f.submit 'Save changes', class: "btn-success btn"
-
- - if @snippet.project_id
- = link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel"
- - else
- = link_to "Cancel", snippets_path(@project), class: "btn btn-cancel"
+- if Feature.disabled?(:monaco_snippets)
+ - content_for :page_specific_javascripts do
+ = page_specific_javascript_tag('lib/ace.js')
+
+- if Feature.enabled?(:snippets_edit_vue)
+ #js-snippet-edit.snippet-form{ data: {'project_path': @snippet.project&.full_path, 'snippet-gid': @snippet.new_record? ? '' : @snippet.to_global_id, 'markdown-preview-path': preview_markdown_path(parent), 'markdown-docs-path': help_page_path('user/markdown'), 'visibility-help-link': help_page_path("public_access/public_access") } }
+- else
+ .snippet-form-holder
+ = form_for @snippet, url: url,
+ html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" },
+ data: { "snippet-type": @snippet.project_id ? 'project' : 'personal'} do |f|
+ = form_errors(@snippet)
+
+ .form-group
+ = f.label :title, class: 'label-bold'
+ = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true
+
+ .form-group.js-description-input
+ - description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...')
+ - is_expanded = @snippet.description && !@snippet.description.empty?
+ = f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold'
+ .js-collapsible-input
+ .js-collapsed{ class: ('d-none' if is_expanded) }
+ = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' }
+ .js-expanded{ class: ('d-none' if !is_expanded) }
+ = render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
+ = render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'description_field'
+ = render 'shared/notes/hints'
+
+ .form-group.file-editor
+ = f.label :file_name, s_('Snippets|File')
+ .file-holder.snippet
+ .js-file-title.file-title-flex-parent
+ = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name qa-snippet-file-name'
+ .file-content.code
+ %pre#editor{ data: { 'editor-loading': true } }= @snippet.content
+ = f.hidden_field :content, class: 'snippet-file-content'
+
+ .form-group
+ .font-weight-bold
+ = _('Visibility level')
+ = link_to icon('question-circle'), help_page_path("public_access/public_access"), target: '_blank'
+ = render 'shared/visibility_level', f: f, visibility_level: @snippet.visibility_level, can_change_visibility_level: true, form_model: @snippet, with_label: false
+
+ - if params[:files]
+ - params[:files].each_with_index do |file, index|
+ = hidden_field_tag "files[]", file, id: "files_#{index}"
+
+ .form-actions
+ - if @snippet.new_record?
+ = f.submit 'Create snippet', class: "btn-success btn qa-create-snippet-button"
+ - else
+ = f.submit 'Save changes', class: "btn-success btn"
+
+ - if @snippet.project_id
+ = link_to "Cancel", project_snippets_path(@project), class: "btn btn-cancel"
+ - else
+ = link_to "Cancel", snippets_path(@project), class: "btn btn-cancel"
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 38f518458d6..57d41bfaec2 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -709,7 +709,7 @@
:urgency: :high
:resource_boundary: :cpu
:weight: 3
- :idempotent:
+ :idempotent: true
- :name: pipeline_creation:create_pipeline
:feature_category: :continuous_integration
:has_external_dependencies:
@@ -1046,6 +1046,13 @@
:resource_boundary: :unknown
:weight: 1
:idempotent:
+- :name: export_csv
+ :feature_category: :issue_tracking
+ :has_external_dependencies:
+ :urgency: :low
+ :resource_boundary: :cpu
+ :weight: 1
+ :idempotent:
- :name: file_hook
:feature_category: :integrations
:has_external_dependencies:
diff --git a/app/workers/concerns/cronjob_queue.rb b/app/workers/concerns/cronjob_queue.rb
index 25ee4539cab..955387b5ad4 100644
--- a/app/workers/concerns/cronjob_queue.rb
+++ b/app/workers/concerns/cronjob_queue.rb
@@ -10,4 +10,14 @@ module CronjobQueue
sidekiq_options retry: false
worker_context project: nil, namespace: nil, user: nil
end
+
+ class_methods do
+ # Cronjobs never get scheduled with arguments, so this is safe to
+ # override
+ def context_for_arguments(_args)
+ return if Gitlab::ApplicationContext.current_context_include?('meta.caller_id')
+
+ Gitlab::ApplicationContext.new(caller_id: "Cronjob")
+ end
+ end
end
diff --git a/app/workers/create_commit_signature_worker.rb b/app/workers/create_commit_signature_worker.rb
index 3da21c56eff..9cbc75f8944 100644
--- a/app/workers/create_commit_signature_worker.rb
+++ b/app/workers/create_commit_signature_worker.rb
@@ -21,14 +21,19 @@ class CreateCommitSignatureWorker # rubocop:disable Scalability/IdempotentWorker
return if commits.empty?
- # This calculates and caches the signature in the database
- commits.each do |commit|
+ # Instantiate commits first to lazily load the signatures
+ commits.map! do |commit|
case commit.signature_type
when :PGP
- Gitlab::Gpg::Commit.new(commit).signature
+ Gitlab::Gpg::Commit.new(commit)
when :X509
- Gitlab::X509::Commit.new(commit).signature
+ Gitlab::X509::Commit.new(commit)
end
+ end
+
+ # This calculates and caches the signature in the database
+ commits.each do |commit|
+ commit&.signature
rescue => e
Rails.logger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}") # rubocop:disable Gitlab/RailsLogger
end
diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb
index 1d2708cdb44..0710ef9298b 100644
--- a/app/workers/expire_pipeline_cache_worker.rb
+++ b/app/workers/expire_pipeline_cache_worker.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ExpirePipelineCacheWorker # rubocop:disable Scalability/IdempotentWorker
+class ExpirePipelineCacheWorker
include ApplicationWorker
include PipelineQueue
@@ -8,6 +8,8 @@ class ExpirePipelineCacheWorker # rubocop:disable Scalability/IdempotentWorker
urgency :high
worker_resource_boundary :cpu
+ idempotent!
+
# rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id)
pipeline = Ci::Pipeline.find_by(id: pipeline_id)
diff --git a/app/workers/export_csv_worker.rb b/app/workers/export_csv_worker.rb
new file mode 100644
index 00000000000..9e2b3ad9bb4
--- /dev/null
+++ b/app/workers/export_csv_worker.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class ExportCsvWorker # rubocop:disable Scalability/IdempotentWorker
+ include ApplicationWorker
+
+ feature_category :issue_tracking
+ worker_resource_boundary :cpu
+
+ def perform(current_user_id, project_id, params)
+ @current_user = User.find(current_user_id)
+ @project = Project.find(project_id)
+
+ params.symbolize_keys!
+ params[:project_id] = project_id
+ params.delete(:sort)
+
+ issues = IssuesFinder.new(@current_user, params).execute
+
+ Issues::ExportCsvService.new(issues, @project).email(@current_user)
+ end
+end
diff --git a/app/workers/gitlab/jira_import/stage/finish_import_worker.rb b/app/workers/gitlab/jira_import/stage/finish_import_worker.rb
index 1d57b77ac7e..3e2cfe56cea 100644
--- a/app/workers/gitlab/jira_import/stage/finish_import_worker.rb
+++ b/app/workers/gitlab/jira_import/stage/finish_import_worker.rb
@@ -10,7 +10,7 @@ module Gitlab
def import(project)
JiraImport.cache_cleanup(project.id)
- project.latest_jira_import&.finish!
+ project.latest_jira_import.finish!
end
end
end
diff --git a/app/workers/project_daily_statistics_worker.rb b/app/workers/project_daily_statistics_worker.rb
index c60bee0ffdc..2166655115d 100644
--- a/app/workers/project_daily_statistics_worker.rb
+++ b/app/workers/project_daily_statistics_worker.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/214585
class ProjectDailyStatisticsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker