summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/avatar_picker.js (renamed from app/assets/javascripts/group_avatar.js)9
-rw-r--r--app/assets/javascripts/boards/models/issue.js12
-rw-r--r--app/assets/javascripts/contextual_sidebar.js3
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue6
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js12
-rw-r--r--app/assets/javascripts/filtered_search/recent_searches_storage_keys.js4
-rw-r--r--app/assets/javascripts/gl_field_errors.js2
-rw-r--r--app/assets/javascripts/helpers/monitor_helper.js17
-rw-r--r--app/assets/javascripts/ide/components/file_row_extra.vue5
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue23
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue14
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/mutations.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/state.js1
-rw-r--r--app/assets/javascripts/jobs/components/empty_state.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue19
-rw-r--r--app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue30
-rw-r--r--app/assets/javascripts/jobs/index.js1
-rw-r--r--app/assets/javascripts/jobs/store/getters.js3
-rw-r--r--app/assets/javascripts/labels_select.js97
-rw-r--r--app/assets/javascripts/monitoring/components/charts/area.vue15
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue44
-rw-r--r--app/assets/javascripts/pages/admin/groups/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/groups/new/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/labels/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/labels/new/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/labels/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/labels/new/index.js2
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js20
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue38
-rw-r--r--app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue55
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue47
-rw-r--r--app/assets/stylesheets/framework/callout.scss4
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss6
-rw-r--r--app/assets/stylesheets/framework/forms.scss4
-rw-r--r--app/assets/stylesheets/pages/diff.scss25
-rw-r--r--app/assets/stylesheets/pages/issuable.scss10
-rw-r--r--app/assets/stylesheets/pages/labels.scss36
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss38
-rw-r--r--app/assets/stylesheets/pages/notes.scss1
-rw-r--r--app/assets/stylesheets/pages/projects.scss4
-rw-r--r--app/controllers/admin/application_settings_controller.rb4
-rw-r--r--app/controllers/concerns/issuable_actions.rb3
-rw-r--r--app/controllers/concerns/issuable_collections.rb1
-rw-r--r--app/controllers/concerns/preview_markdown.rb2
-rw-r--r--app/controllers/concerns/project_unauthorized.rb19
-rw-r--r--app/controllers/dashboard_controller.rb5
-rw-r--r--app/controllers/groups_controller.rb4
-rw-r--r--app/controllers/projects/application_controller.rb6
-rw-r--r--app/controllers/projects/commits_controller.rb2
-rw-r--r--app/controllers/projects/environments/prometheus_api_controller.rb37
-rw-r--r--app/controllers/projects/environments_controller.rb14
-rw-r--r--app/controllers/projects/git_http_client_controller.rb6
-rw-r--r--app/controllers/projects/jobs_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests_controller.rb6
-rw-r--r--app/controllers/projects/pipelines_controller.rb12
-rw-r--r--app/controllers/projects/repositories_controller.rb2
-rw-r--r--app/controllers/projects/wikis_controller.rb5
-rw-r--r--app/controllers/projects_controller.rb3
-rw-r--r--app/finders/issuable_finder.rb47
-rw-r--r--app/finders/merge_requests_finder.rb13
-rw-r--r--app/graphql/gitlab_schema.rb31
-rw-r--r--app/graphql/types/base_field.rb9
-rw-r--r--app/graphql/types/ci/pipeline_type.rb6
-rw-r--r--app/graphql/types/issue_type.rb12
-rw-r--r--app/graphql/types/merge_request_type.rb8
-rw-r--r--app/graphql/types/milestone_type.rb2
-rw-r--r--app/graphql/types/project_type.rb14
-rw-r--r--app/graphql/types/query_type.rb3
-rw-r--r--app/graphql/types/user_type.rb2
-rw-r--r--app/helpers/application_settings_helper.rb45
-rw-r--r--app/helpers/blob_helper.rb14
-rw-r--r--app/helpers/builds_helper.rb6
-rw-r--r--app/helpers/button_helper.rb2
-rw-r--r--app/helpers/form_helper.rb5
-rw-r--r--app/helpers/labels_helper.rb59
-rw-r--r--app/helpers/projects_helper.rb14
-rw-r--r--app/helpers/search_helper.rb50
-rw-r--r--app/helpers/tree_helper.rb10
-rw-r--r--app/helpers/visibility_level_helper.rb16
-rw-r--r--app/helpers/wiki_helper.rb20
-rw-r--r--app/models/application_setting.rb41
-rw-r--r--app/models/ci/build.rb13
-rw-r--r--app/models/ci/build_runner_session.rb18
-rw-r--r--app/models/concerns/artifact_migratable.rb14
-rw-r--r--app/models/concerns/issuable.rb4
-rw-r--r--app/models/concerns/prometheus_adapter.rb2
-rw-r--r--app/models/concerns/reactive_caching.rb39
-rw-r--r--app/models/concerns/sortable.rb28
-rw-r--r--app/models/environment.rb6
-rw-r--r--app/models/global_label.rb2
-rw-r--r--app/models/group.rb7
-rw-r--r--app/models/issue.rb10
-rw-r--r--app/models/project.rb5
-rw-r--r--app/models/project_wiki.rb16
-rw-r--r--app/models/repository.rb5
-rw-r--r--app/models/user.rb2
-rw-r--r--app/models/wiki_page.rb19
-rw-r--r--app/policies/base_policy.rb9
-rw-r--r--app/policies/project_policy.rb29
-rw-r--r--app/presenters/ci/bridge_presenter.rb9
-rw-r--r--app/serializers/build_details_entity.rb2
-rw-r--r--app/serializers/issue_entity.rb2
-rw-r--r--app/serializers/merge_request_widget_entity.rb2
-rw-r--r--app/serializers/suggestion_entity.rb2
-rw-r--r--app/serializers/suggestion_serializer.rb9
-rw-r--r--app/services/application_settings/update_service.rb8
-rw-r--r--app/services/clusters/applications/base_helm_service.rb12
-rw-r--r--app/services/concerns/suggestible.rb7
-rw-r--r--app/services/concerns/validates_classification_label.rb27
-rw-r--r--app/services/groups/base_service.rb6
-rw-r--r--app/services/groups/create_service.rb2
-rw-r--r--app/services/groups/update_service.rb1
-rw-r--r--app/services/issuable_base_service.rb5
-rw-r--r--app/services/merge_requests/add_todo_when_build_fails_service.rb4
-rw-r--r--app/services/merge_requests/base_service.rb33
-rw-r--r--app/services/preview_markdown_service.rb28
-rw-r--r--app/services/projects/create_service.rb4
-rw-r--r--app/services/projects/update_service.rb3
-rw-r--r--app/services/prometheus/proxy_service.rb116
-rw-r--r--app/uploaders/legacy_artifact_uploader.rb3
-rw-r--r--app/uploaders/records_uploads.rb4
-rw-r--r--app/validators/x509_certificate_credentials_validator.rb86
-rw-r--r--app/views/admin/application_settings/_external_authorization_service_form.html.haml51
-rw-r--r--app/views/admin/application_settings/show.html.haml2
-rw-r--r--app/views/admin/groups/_form.html.haml2
-rw-r--r--app/views/clusters/clusters/gcp/_form.html.haml47
-rw-r--r--app/views/clusters/clusters/user/_form.html.haml61
-rw-r--r--app/views/clusters/platforms/kubernetes/_form.html.haml89
-rw-r--r--app/views/groups/new.html.haml2
-rw-r--r--app/views/groups/settings/_general.html.haml4
-rw-r--r--app/views/layouts/_page.html.haml1
-rw-r--r--app/views/layouts/nav/_classification_level_banner.html.haml5
-rw-r--r--app/views/projects/_classification_policy_settings.html.haml8
-rw-r--r--app/views/projects/blob/viewers/_route_map.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_route_map_loading.html.haml2
-rw-r--r--app/views/projects/buttons/_download.html.haml36
-rw-r--r--app/views/projects/buttons/_download_links.html.haml9
-rw-r--r--app/views/projects/diffs/_replaced_image_diff.html.haml6
-rw-r--r--app/views/projects/diffs/_single_image_diff.html.haml2
-rw-r--r--app/views/projects/edit.html.haml29
-rw-r--r--app/views/projects/jobs/show.html.haml1
-rw-r--r--app/views/projects/pipelines/charts.html.haml4
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml9
-rw-r--r--app/views/projects/wikis/pages.html.haml13
-rw-r--r--app/views/shared/_choose_avatar_button.html.haml4
-rw-r--r--app/views/shared/_choose_group_avatar_button.html.haml4
-rw-r--r--app/views/shared/_delete_label_modal.html.haml2
-rw-r--r--app/views/shared/_label_row.html.haml2
-rw-r--r--app/views/shared/boards/components/sidebar/_labels.html.haml8
-rw-r--r--app/views/shared/form_elements/_description.html.haml2
-rw-r--r--app/views/shared/issuable/_label_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml8
-rw-r--r--app/views/shared/labels/_form.html.haml4
-rw-r--r--app/views/shared/milestones/_issuable.html.haml3
-rw-r--r--app/views/shared/milestones/_labels_tab.html.haml3
-rw-r--r--app/views/shared/notes/_form.html.haml2
-rw-r--r--app/workers/reactive_caching_worker.rb7
171 files changed, 1858 insertions, 588 deletions
diff --git a/app/assets/javascripts/group_avatar.js b/app/assets/javascripts/avatar_picker.js
index dcda625f587..d38e0b4abaa 100644
--- a/app/assets/javascripts/group_avatar.js
+++ b/app/assets/javascripts/avatar_picker.js
@@ -1,11 +1,12 @@
import $ from 'jquery';
-export default function groupAvatar() {
- $('.js-choose-group-avatar-button').on('click', function onClickGroupAvatar() {
+export default function initAvatarPicker() {
+ $('.js-choose-avatar-button').on('click', function onClickAvatar() {
const form = $(this).closest('form');
- return form.find('.js-group-avatar-input').click();
+ return form.find('.js-avatar-input').click();
});
- $('.js-group-avatar-input').on('change', function onChangeAvatarInput() {
+
+ $('.js-avatar-input').on('change', function onChangeAvatarInput() {
const form = $(this).closest('form');
const filename = $(this)
.val()
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index dd92d3c8552..2edb6723ada 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -119,7 +119,17 @@ class ListIssue {
}
const projectPath = this.project ? this.project.path : '';
- return Vue.http.patch(`${this.path}.json`, data);
+ return Vue.http.patch(`${this.path}.json`, data).then(({ body = {} } = {}) => {
+ /**
+ * Since post implementation of Scoped labels, server can reject
+ * same key-ed labels. To keep the UI and server Model consistent,
+ * we're just assigning labels that server echo's back to us when we
+ * PATCH the said object.
+ */
+ if (body) {
+ this.labels = body.labels;
+ }
+ });
}
}
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index 67fcdd082a2..03dea1ec0a5 100644
--- a/app/assets/javascripts/contextual_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -41,6 +41,9 @@ export default class ContextualSidebar {
this.toggleCollapsedSidebar(value, true);
}
});
+ this.$page.on('transitionstart transitionend', () => {
+ $(document).trigger('content.resize');
+ });
$(window).on('resize', () => _.debounce(this.render(), 100));
}
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index bb66ab36283..41670b45798 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -48,10 +48,13 @@ export default {
noteableType: this.noteableType,
noteTargetLine: this.noteTargetLine,
diffViewType: this.diffViewType,
- diffFile: this.getDiffFileByHash(this.diffFileHash),
+ diffFile: this.diffFile,
linePosition: this.linePosition,
};
},
+ diffFile() {
+ return this.getDiffFileByHash(this.diffFileHash);
+ },
},
mounted() {
if (this.isLoggedIn) {
@@ -102,6 +105,7 @@ export default {
:line-code="line.line_code"
:line="line"
:help-page-path="helpPagePath"
+ :diff-file="diffFile"
save-button-title="Comment"
class="diff-comment-form"
@handleFormUpdateAddToReview="addToReview"
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 0c2e87521d9..efd03ec952f 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,6 +1,7 @@
import _ from 'underscore';
import { getParameterByName, getUrlParamsArray } from '~/lib/utils/common_utils';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
import { visitUrl } from '../lib/utils/url_utility';
import Flash from '../flash';
import FilteredSearchContainer from './container';
@@ -36,10 +37,11 @@ export default class FilteredSearchManager {
this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = filteredSearchTokenKeys;
this.stateFiltersSelector = stateFiltersSelector;
- this.recentsStorageKeyNames = {
- issues: 'issue-recent-searches',
- merge_requests: 'merge-request-recent-searches',
- };
+
+ const { multipleAssignees } = this.filteredSearchInput.dataset;
+ if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) {
+ this.filteredSearchTokenKeys.enableMultipleAssignees();
+ }
this.recentSearchesStore = new RecentSearchesStore({
isLocalStorageAvailable: RecentSearchesService.isAvailable(),
@@ -51,7 +53,7 @@ export default class FilteredSearchManager {
const fullPath = this.searchHistoryDropdownElement
? this.searchHistoryDropdownElement.dataset.fullPath
: 'project';
- const recentSearchesKey = `${fullPath}-${this.recentsStorageKeyNames[this.page]}`;
+ const recentSearchesKey = `${fullPath}-${recentSearchesStorageKeys[this.page]}`;
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
}
diff --git a/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js
new file mode 100644
index 00000000000..7e9b809e9b2
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js
@@ -0,0 +1,4 @@
+export default {
+ issues: 'issue-recent-searches',
+ merge_requests: 'merge-request-recent-searches',
+};
diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js
index d5d5954ce6a..c4fd719c8d0 100644
--- a/app/assets/javascripts/gl_field_errors.js
+++ b/app/assets/javascripts/gl_field_errors.js
@@ -15,7 +15,7 @@ export default class GlFieldErrors {
initValidators() {
// register selectors here as needed
- const validateSelectors = [':text', ':password', '[type=email]']
+ const validateSelectors = [':text', ':password', '[type=email]', '[type=url]', '[type=number]']
.map(selector => `input${selector}`)
.join(',');
diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js
new file mode 100644
index 00000000000..2c2a04d5b5e
--- /dev/null
+++ b/app/assets/javascripts/helpers/monitor_helper.js
@@ -0,0 +1,17 @@
+/* eslint-disable import/prefer-default-export */
+
+export const makeDataSeries = (queryResults, defaultConfig) =>
+ queryResults.reduce((acc, result) => {
+ const data = result.values.filter(([, value]) => !Number.isNaN(value));
+ if (!data.length) {
+ return acc;
+ }
+ const relevantMetric = defaultConfig.name.toLowerCase().replace(' ', '_');
+ const name = result.metric[relevantMetric];
+ const series = { data };
+ if (name) {
+ series.name = `${defaultConfig.name}: ${name}`;
+ }
+
+ return acc.concat({ ...defaultConfig, ...series });
+ }, []);
diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue
index d6673cf0421..80a6ab9598a 100644
--- a/app/assets/javascripts/ide/components/file_row_extra.vue
+++ b/app/assets/javascripts/ide/components/file_row_extra.vue
@@ -23,7 +23,7 @@ export default {
type: Object,
required: true,
},
- mouseOver: {
+ dropdownOpen: {
type: Boolean,
required: true,
},
@@ -92,8 +92,9 @@ export default {
<new-dropdown
:type="file.type"
:path="file.path"
- :mouse-over="mouseOver"
+ :is-open="dropdownOpen"
class="prepend-left-8"
+ v-on="$listeners"
/>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index 593a9162a06..27d24fa5e1d 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -21,38 +21,29 @@ export default {
required: false,
default: '',
},
- mouseOver: {
+ isOpen: {
type: Boolean,
- required: true,
+ required: false,
+ default: false,
},
},
- data() {
- return {
- dropdownOpen: false,
- };
- },
watch: {
- dropdownOpen() {
+ isOpen() {
this.$nextTick(() => {
this.$refs.dropdownMenu.scrollIntoView({
block: 'nearest',
});
});
},
- mouseOver() {
- if (!this.mouseOver) {
- this.dropdownOpen = false;
- }
- },
},
methods: {
...mapActions(['createTempEntry', 'openNewEntryModal', 'deleteEntry']),
createNewItem(type) {
this.openNewEntryModal({ type, path: this.path });
- this.dropdownOpen = false;
+ this.$emit('toggle', false);
},
openDropdown() {
- this.dropdownOpen = !this.dropdownOpen;
+ this.$emit('toggle', !this.isOpen);
},
},
modalTypes,
@@ -63,7 +54,7 @@ export default {
<div class="ide-new-btn">
<div
:class="{
- show: dropdownOpen,
+ show: isOpen,
}"
class="dropdown d-flex"
>
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index 451c8030e16..5ae73b2fc9c 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -24,7 +24,13 @@ export default {
...mapState(['pipelinesEmptyStateSvgPath', 'links']),
...mapGetters(['currentProject']),
...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']),
- ...mapState('pipelines', ['isLoadingPipeline', 'latestPipeline', 'stages', 'isLoadingJobs']),
+ ...mapState('pipelines', [
+ 'isLoadingPipeline',
+ 'hasLoadedPipeline',
+ 'latestPipeline',
+ 'stages',
+ 'isLoadingJobs',
+ ]),
ciLintText() {
return sprintf(
__('You can test your .gitlab-ci.yml in %{linkStart}CI Lint%{linkEnd}.'),
@@ -36,7 +42,7 @@ export default {
);
},
showLoadingIcon() {
- return this.isLoadingPipeline && this.latestPipeline === null;
+ return this.isLoadingPipeline && !this.hasLoadedPipeline;
},
},
created() {
@@ -51,7 +57,7 @@ export default {
<template>
<div class="ide-pipeline">
<gl-loading-icon v-if="showLoadingIcon" :size="2" class="prepend-top-default" />
- <template v-else-if="latestPipeline !== null">
+ <template v-else-if="hasLoadedPipeline">
<header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header">
<ci-icon :status="latestPipeline.details.status" :size="24" />
<span class="prepend-left-8">
@@ -62,7 +68,7 @@ export default {
</span>
</header>
<empty-state
- v-if="latestPipeline === false"
+ v-if="!latestPipeline"
:help-page-path="links.ciHelpPagePath"
:empty-state-svg-path="pipelinesEmptyStateSvgPath"
:can-set-ci="true"
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
index 362ced248a1..1273e375859 100644
--- a/app/assets/javascripts/ide/stores/actions/merge_request.js
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -4,10 +4,11 @@ import service from '../../services';
import * as types from '../mutation_types';
import { activityBarViews } from '../../constants';
-export const getMergeRequestsForBranch = ({ commit }, { projectId, branchId } = {}) =>
+export const getMergeRequestsForBranch = ({ commit, state }, { projectId, branchId } = {}) =>
service
.getProjectMergeRequests(`${projectId}`, {
source_branch: branchId,
+ source_project_id: state.projects[projectId].id,
order_by: 'created_at',
per_page: 1,
})
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
index b4be100cb07..eaaa82cb339 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
@@ -10,6 +10,7 @@ export default {
},
[types.RECEIVE_LASTEST_PIPELINE_SUCCESS](state, pipeline) {
state.isLoadingPipeline = false;
+ state.hasLoadedPipeline = true;
if (pipeline) {
state.latestPipeline = {
@@ -34,7 +35,7 @@ export default {
};
});
} else {
- state.latestPipeline = false;
+ state.latestPipeline = null;
}
},
[types.REQUEST_JOBS](state, id) {
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/state.js b/app/assets/javascripts/ide/stores/modules/pipelines/state.js
index 8651e267b53..8dfa0ec491f 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/state.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/state.js
@@ -1,5 +1,6 @@
export default () => ({
isLoadingPipeline: true,
+ hasLoadedPipeline: false,
isLoadingJobs: false,
latestPipeline: null,
stages: [],
diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/empty_state.vue
index 668fcf3d673..04f910b6b80 100644
--- a/app/assets/javascripts/jobs/components/empty_state.vue
+++ b/app/assets/javascripts/jobs/components/empty_state.vue
@@ -49,7 +49,7 @@ export default {
<div class="text-content">
<h4 class="js-job-empty-state-title text-center">{{ title }}</h4>
- <p v-if="content" class="js-job-empty-state-content">{{ content }}</p>
+ <p v-if="content" class="js-job-empty-state-content text-center">{{ content }}</p>
<div v-if="action" class="text-center">
<gl-link
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index dbadd224251..0670e2b06b9 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -15,6 +15,7 @@ import ErasedBlock from './erased_block.vue';
import Log from './job_log.vue';
import LogTopBar from './job_log_controllers.vue';
import StuckBlock from './stuck_block.vue';
+import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue';
import Sidebar from './sidebar.vue';
import { sprintf } from '~/locale';
import delayedJobMixin from '../mixins/delayed_job_mixin';
@@ -32,6 +33,7 @@ export default {
Log,
LogTopBar,
StuckBlock,
+ UnmetPrerequisitesBlock,
Sidebar,
GlLoadingIcon,
SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'),
@@ -48,6 +50,11 @@ export default {
required: false,
default: null,
},
+ deploymentHelpUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
endpoint: {
type: String,
required: true,
@@ -82,6 +89,7 @@ export default {
]),
...mapGetters([
'headerTime',
+ 'hasUnmetPrerequisitesFailure',
'shouldRenderCalloutMessage',
'shouldRenderTriggeredLabel',
'hasEnvironment',
@@ -210,7 +218,10 @@ export default {
/>
</div>
- <callout v-if="shouldRenderCalloutMessage" :message="job.callout_message" />
+ <callout
+ v-if="shouldRenderCalloutMessage && !hasUnmetPrerequisitesFailure"
+ :message="job.callout_message"
+ />
</header>
<!-- EO Header Section -->
@@ -223,6 +234,12 @@ export default {
:runners-path="runnerSettingsUrl"
/>
+ <unmet-prerequisites-block
+ v-if="hasUnmetPrerequisitesFailure"
+ class="js-job-failed"
+ :help-path="deploymentHelpUrl"
+ />
+
<shared-runner
v-if="shouldRenderSharedRunnerLimitWarning"
class="js-shared-runner-limit"
diff --git a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue
new file mode 100644
index 00000000000..25a8da84873
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue
@@ -0,0 +1,30 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+/**
+ * Renders Unmet Prerequisites block for job's view.
+ */
+export default {
+ components: {
+ GlLink,
+ },
+ props: {
+ helpPath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="bs-callout bs-callout-danger">
+ <p class="js-failed-unmet-prerequisites append-bottom-0">
+ {{
+ s__(`Job|This job failed because the necessary resources were not successfully created.`)
+ }}
+
+ <gl-link :href="helpPath" class="js-help-path">
+ <strong> {{ __('More information') }} </strong>
+ </gl-link>
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index a32e945627c..25132449458 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -12,6 +12,7 @@ export default () => {
render(createElement) {
return createElement('job-app', {
props: {
+ deploymentHelpUrl: element.dataset.deploymentHelpUrl,
runnerHelpUrl: element.dataset.runnerHelpUrl,
runnerSettingsUrl: element.dataset.runnerSettingsUrl,
endpoint: element.dataset.endpoint,
diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js
index 73c1cbc3a99..406b1a2e375 100644
--- a/app/assets/javascripts/jobs/store/getters.js
+++ b/app/assets/javascripts/jobs/store/getters.js
@@ -3,6 +3,9 @@ import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at);
+export const hasUnmetPrerequisitesFailure = state =>
+ state.job && state.job.failure_reason && state.job.failure_reason === 'unmet_prerequisites';
+
export const shouldRenderCalloutMessage = state =>
!_.isEmpty(state.job.status) && !_.isEmpty(state.job.callout_message);
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index cca4927c115..7d21a216443 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -11,6 +11,7 @@ import CreateLabelDropdown from './create_label';
import flash from './flash';
import ModalStore from './boards/stores/modal_store';
import boardsStore from './boards/stores/boards_store';
+import { isEE } from '~/lib/utils/common_utils';
export default class LabelsSelect {
constructor(els, options = {}) {
@@ -86,8 +87,9 @@ export default class LabelsSelect {
return this.value;
})
.get();
+ const scopedLabels = $dropdown.data('scopedLabels');
+ const scopedLabelsDocumentationLink = $dropdown.data('scopedLabelsDocumentationLink');
const { handleClick } = options;
-
$sidebarLabelTooltip.tooltip();
if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) {
@@ -132,8 +134,49 @@ export default class LabelsSelect {
template = LabelsSelect.getLabelTemplate({
labels: data.labels,
issueUpdateURL,
+ enableScopedLabels: scopedLabels,
+ scopedLabelsDocumentationLink,
});
labelCount = data.labels.length;
+
+ // EE Specific
+ if (isEE) {
+ /**
+ * For Scoped labels, the last label selected with the
+ * same key will be applied to the current issueable.
+ *
+ * If these are the labels - priority::1, priority::2; and if
+ * we apply them in the same order, only priority::2 will stick
+ * with the issuable.
+ *
+ * In the current dropdown implementation, we keep track of all
+ * the labels selected via a hidden DOM element. Since a User
+ * can select priority::1 and priority::2 at the same time, the
+ * DOM will have 2 hidden input and the dropdown will show both
+ * the items selected but in reality server only applied
+ * priority::2.
+ *
+ * We find all the labels then find all the labels server accepted
+ * and then remove the excess ones.
+ */
+ const toRemoveIds = Array.from(
+ $form.find(`input[type="hidden"][name="${fieldName}"]`),
+ )
+ .map(el => el.value)
+ .map(Number);
+
+ data.labels.forEach(label => {
+ const index = toRemoveIds.indexOf(label.id);
+ toRemoveIds.splice(index, 1);
+ });
+
+ toRemoveIds.forEach(id => {
+ $form
+ .find(`input[type="hidden"][name="${fieldName}"][value="${id}"]`)
+ .last()
+ .remove();
+ });
+ }
} else {
template = '<span class="no-value">None</span>';
}
@@ -358,6 +401,7 @@ export default class LabelsSelect {
} else {
if (!$dropdown.hasClass('js-filter-bulk-update')) {
saveLabelData();
+ $dropdown.data('glDropdown').clearMenu();
}
}
}
@@ -471,19 +515,62 @@ export default class LabelsSelect {
// so best approach is to use traditional way of
// concatenation
// see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays
- const tpl = _.template(
+
+ const labelTemplate = _.template(
[
- '<% _.each(labels, function(label){ %>',
'<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">',
- '<span class="badge label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">',
+ '<span class="badge label has-tooltip color-label" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;">',
'<%- label.title %>',
'</span>',
'</a>',
+ ].join(''),
+ );
+
+ const infoIconTemplate = _.template(
+ [
+ '<a href="<%= scopedLabelsDocumentationLink %>" class="label scoped-label" target="_blank" rel="noopener">',
+ '<i class="fa fa-question-circle" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;"></i>',
+ '</a>',
+ ].join(''),
+ );
+
+ const tooltipTitleTemplate = _.template(
+ [
+ '<% if (isScopedLabel(label) && enableScopedLabels) { %>',
+ "<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>",
+ '<br />',
+ '<%= escapeStr(label.description) %>',
+ '<% } else { %>',
+ '<%= escapeStr(label.description) %>',
+ '<% } %>',
+ ].join(''),
+ );
+
+ const isScopedLabel = label => label.title.indexOf('::') !== -1;
+
+ const tpl = _.template(
+ [
+ '<% _.each(labels, function(label){ %>',
+ '<% if (isScopedLabel(label) && enableScopedLabels) { %>',
+ '<span class="d-inline-block position-relative scoped-label-wrapper">',
+ '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>',
+ '<%= infoIconTemplate({ label, scopedLabelsDocumentationLink, escapeStr }) %>',
+ '</span>',
+ '<% } else { %>',
+ '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>',
+ '<% } %>',
'<% }); %>',
].join(''),
);
- return tpl(tplData);
+ return tpl({
+ ...tplData,
+ labelTemplate,
+ infoIconTemplate,
+ tooltipTitleTemplate,
+ isScopedLabel,
+ escapeStr: _.escape,
+ });
}
bindEvents() {
diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue
index d453dc1fdb7..b0bbe272d1f 100644
--- a/app/assets/javascripts/monitoring/components/charts/area.vue
+++ b/app/assets/javascripts/monitoring/components/charts/area.vue
@@ -5,6 +5,7 @@ import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
import { chartHeight, graphTypes, lineTypes } from '../../constants';
+import { makeDataSeries } from '~/helpers/monitor_helper';
let debouncedResize;
@@ -63,7 +64,7 @@ export default {
},
computed: {
chartData() {
- return this.graphData.queries.map(query => {
+ return this.graphData.queries.reduce((acc, query) => {
const { appearance } = query;
const lineType =
appearance && appearance.line && appearance.line.type
@@ -74,9 +75,8 @@ export default {
? appearance.line.width
: undefined;
- return {
+ const series = makeDataSeries(query.result, {
name: this.formatLegendLabel(query),
- data: this.concatenateResults(query.result),
lineStyle: {
type: lineType,
width: lineWidth,
@@ -87,8 +87,10 @@ export default {
? appearance.area.opacity
: undefined,
},
- };
- });
+ });
+
+ return acc.concat(series);
+ }, []);
},
chartOptions() {
return {
@@ -175,9 +177,6 @@ export default {
this.setSvg('scroll-handle');
},
methods: {
- concatenateResults(results) {
- return results.reduce((acc, result) => acc.concat(result.values), []);
- },
formatLegendLabel(query) {
return `${query.label}`;
},
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 57d6b181bd7..471323bfc83 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -61,6 +61,11 @@ export default {
required: false,
default: null,
},
+ diffFile: {
+ type: Object,
+ required: false,
+ default: null,
+ },
helpPagePath: {
type: String,
required: false,
@@ -102,9 +107,42 @@ export default {
}
return '#';
},
+ diffParams() {
+ if (this.diffFile) {
+ return {
+ filePath: this.diffFile.file_path,
+ refs: this.diffFile.diff_refs,
+ };
+ } else if (this.note && this.note.position) {
+ return {
+ filePath: this.note.position.new_path,
+ refs: this.note.position,
+ };
+ } else if (this.discussion && this.discussion.diff_file) {
+ return {
+ filePath: this.discussion.diff_file.file_path,
+ refs: this.discussion.diff_file.diff_refs,
+ };
+ }
+
+ return null;
+ },
markdownPreviewPath() {
const notable = this.getNoteableDataByProp('preview_note_path');
- return mergeUrlParams({ preview_suggestions: true }, notable);
+
+ const previewSuggestions = this.line && this.diffParams;
+ const params = previewSuggestions
+ ? {
+ preview_suggestions: previewSuggestions,
+ line: this.line.new_line,
+ file_path: this.diffParams.filePath,
+ base_sha: this.diffParams.refs.base_sha,
+ start_sha: this.diffParams.refs.start_sha,
+ head_sha: this.diffParams.refs.head_sha,
+ }
+ : {};
+
+ return mergeUrlParams(params, notable);
},
markdownDocsPath() {
return this.getNotesDataByProp('markdownDocsPath');
@@ -234,8 +272,8 @@ export default {
placeholder="Write a comment or drag your files here…"
@keydown.meta.enter="handleKeySubmit()"
@keydown.ctrl.enter="handleKeySubmit()"
- @keydown.up="editMyLastNote()"
- @keydown.esc="cancelHandler(true)"
+ @keydown.exact.up="editMyLastNote()"
+ @keydown.exact.esc="cancelHandler(true)"
@input="onInput"
></textarea>
</markdown-field>
diff --git a/app/assets/javascripts/pages/admin/groups/edit/index.js b/app/assets/javascripts/pages/admin/groups/edit/index.js
index d3d125a1859..ad7276132b9 100644
--- a/app/assets/javascripts/pages/admin/groups/edit/index.js
+++ b/app/assets/javascripts/pages/admin/groups/edit/index.js
@@ -1,3 +1,3 @@
-import groupAvatar from '~/group_avatar';
+import initAvatarPicker from '~/avatar_picker';
-document.addEventListener('DOMContentLoaded', groupAvatar);
+document.addEventListener('DOMContentLoaded', initAvatarPicker);
diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js
index 21f1ce222ac..6de740ee9ce 100644
--- a/app/assets/javascripts/pages/admin/groups/new/index.js
+++ b/app/assets/javascripts/pages/admin/groups/new/index.js
@@ -1,9 +1,9 @@
import BindInOut from '../../../../behaviors/bind_in_out';
import Group from '../../../../group';
-import groupAvatar from '../../../../group_avatar';
+import initAvatarPicker from '~/avatar_picker';
document.addEventListener('DOMContentLoaded', () => {
BindInOut.initAll();
new Group(); // eslint-disable-line no-new
- groupAvatar();
+ initAvatarPicker();
});
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index 01ef445c901..d036ff07d89 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -1,4 +1,4 @@
-import groupAvatar from '~/group_avatar';
+import initAvatarPicker from '~/avatar_picker';
import TransferDropdown from '~/groups/transfer_dropdown';
import initConfirmDangerModal from '~/confirm_danger_modal';
import initSettingsPanels from '~/settings_panels';
@@ -9,7 +9,7 @@ import groupsSelect from '~/groups_select';
import projectSelect from '~/project_select';
document.addEventListener('DOMContentLoaded', () => {
- groupAvatar();
+ initAvatarPicker();
new TransferDropdown(); // eslint-disable-line no-new
initConfirmDangerModal();
initSettingsPanels();
diff --git a/app/assets/javascripts/pages/groups/labels/edit/index.js b/app/assets/javascripts/pages/groups/labels/edit/index.js
index fa81ad914ba..83d6ac9fd14 100644
--- a/app/assets/javascripts/pages/groups/labels/edit/index.js
+++ b/app/assets/javascripts/pages/groups/labels/edit/index.js
@@ -1,3 +1,3 @@
-import Labels from '~/labels';
+import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/groups/labels/new/index.js b/app/assets/javascripts/pages/groups/labels/new/index.js
index fa81ad914ba..83d6ac9fd14 100644
--- a/app/assets/javascripts/pages/groups/labels/new/index.js
+++ b/app/assets/javascripts/pages/groups/labels/new/index.js
@@ -1,3 +1,3 @@
-import Labels from '~/labels';
+import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index b2f275dc5ea..57b53eb9e5d 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -1,9 +1,9 @@
import BindInOut from '~/behaviors/bind_in_out';
import Group from '~/group';
-import groupAvatar from '~/group_avatar';
+import initAvatarPicker from '~/avatar_picker';
document.addEventListener('DOMContentLoaded', () => {
BindInOut.initAll();
new Group(); // eslint-disable-line no-new
- groupAvatar();
+ initAvatarPicker();
});
diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js
index 899d5925956..278c35d3846 100644
--- a/app/assets/javascripts/pages/projects/edit/index.js
+++ b/app/assets/javascripts/pages/projects/edit/index.js
@@ -3,7 +3,7 @@ import initSettingsPanels from '~/settings_panels';
import setupProjectEdit from '~/project_edit';
import initConfirmDangerModal from '~/confirm_danger_modal';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
-import fileUpload from '~/lib/utils/file_upload';
+import initAvatarPicker from '~/avatar_picker';
import initProjectLoadingSpinner from '../shared/save_project_loader';
import initProjectPermissionsSettings from '../shared/permissions';
@@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', () => {
setupProjectEdit();
// Initialize expandable settings panels
initSettingsPanels();
- fileUpload('.js-choose-project-avatar-button', '.js-project-avatar-input');
+ initAvatarPicker();
initProjectPermissionsSettings();
initConfirmDangerModal();
mountBadgeSettings(PROJECT_BADGE);
diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js
index fa81ad914ba..83d6ac9fd14 100644
--- a/app/assets/javascripts/pages/projects/labels/edit/index.js
+++ b/app/assets/javascripts/pages/projects/labels/edit/index.js
@@ -1,3 +1,3 @@
-import Labels from '~/labels';
+import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/projects/labels/new/index.js b/app/assets/javascripts/pages/projects/labels/new/index.js
index fa81ad914ba..83d6ac9fd14 100644
--- a/app/assets/javascripts/pages/projects/labels/new/index.js
+++ b/app/assets/javascripts/pages/projects/labels/new/index.js
@@ -1,3 +1,3 @@
-import Labels from '~/labels';
+import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
index 706e6ca19c3..57125c78cf6 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -50,6 +50,9 @@ export default {
buttonLabel() {
return this.isTodo ? MARK_TEXT : TODO_TEXT;
},
+ buttonTooltip() {
+ return !this.collapsed ? undefined : this.buttonLabel;
+ },
collapsedButtonIconClasses() {
return this.isTodo ? 'todo-undone' : '';
},
@@ -69,7 +72,7 @@ export default {
<button
v-tooltip
:class="buttonClasses"
- :title="buttonLabel"
+ :title="buttonTooltip"
:aria-label="buttonLabel"
:data-issuable-id="issuableId"
:data-issuable-type="issuableType"
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 0cbcdbf2eb4..1bfa91500cb 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -39,7 +39,7 @@ export default {
},
data() {
return {
- mouseOver: false,
+ dropdownOpen: false,
};
},
computed: {
@@ -123,8 +123,8 @@ export default {
return this.$router.currentRoute.path === `/project${this.file.url}`;
},
- toggleHover(over) {
- this.mouseOver = over;
+ toggleDropdown(val) {
+ this.dropdownOpen = val;
},
},
};
@@ -140,8 +140,7 @@ export default {
class="file-row"
role="button"
@click="clickFile"
- @mouseover="toggleHover(true)"
- @mouseout="toggleHover(false)"
+ @mouseleave="toggleDropdown(false)"
>
<div class="file-row-name-container">
<span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated">
@@ -160,7 +159,8 @@ export default {
:is="extraComponent"
v-if="extraComponent && !(hideExtraOnTree && file.type === 'tree')"
:file="file"
- :mouse-over="mouseOver"
+ :dropdown-open="dropdownOpen"
+ @toggle="toggleDropdown($event)"
/>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js
new file mode 100644
index 00000000000..d1aba99ac22
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js
@@ -0,0 +1,20 @@
+/* eslint-disable import/prefer-default-export */
+
+function trimFirstCharOfLineContent(text) {
+ if (!text) {
+ return text;
+ }
+
+ return text.replace(/^( |\+|-)/, '');
+}
+
+function cleanSuggestionLine(line = {}) {
+ return {
+ ...line,
+ text: trimFirstCharOfLineContent(line.text),
+ };
+}
+
+export function selectDiffLines(lines) {
+ return lines.filter(line => line.type !== 'match').map(line => cleanSuggestionLine(line));
+}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index eccf73e227c..0f3b3568414 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -76,6 +76,7 @@ export default {
hasSuggestion: false,
markdownPreviewLoading: false,
previewMarkdown: false,
+ suggestions: this.note.suggestions || [],
};
},
computed: {
@@ -109,9 +110,6 @@ export default {
}
return lineNumber;
},
- suggestions() {
- return this.note.suggestions || [];
- },
lineType() {
return this.line ? this.line.type : '';
},
@@ -175,6 +173,7 @@ export default {
this.referencedCommands = data.references.commands;
this.referencedUsers = data.references.users;
this.hasSuggestion = data.references.suggestions && data.references.suggestions.length;
+ this.suggestions = data.references.suggestions;
}
this.$nextTick()
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index cc6ecdb0395..a5a5b2ef415 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -38,7 +38,7 @@ export default {
].join('\n');
},
mdSuggestion() {
- return ['```suggestion', `{text}`, '```'].join('\n');
+ return ['```suggestion:-0+0', `{text}`, '```'].join('\n');
},
},
mounted() {
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index a351ca62c94..2eb4ec12a4a 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -1,24 +1,14 @@
<script>
import SuggestionDiffHeader from './suggestion_diff_header.vue';
+import SuggestionDiffRow from './suggestion_diff_row.vue';
+import { selectDiffLines } from '../lib/utils/diff_utils';
export default {
components: {
SuggestionDiffHeader,
+ SuggestionDiffRow,
},
props: {
- newLines: {
- type: Array,
- required: true,
- },
- fromContent: {
- type: String,
- required: false,
- default: '',
- },
- fromLine: {
- type: Number,
- required: true,
- },
suggestion: {
type: Object,
required: true,
@@ -33,6 +23,11 @@ export default {
required: true,
},
},
+ computed: {
+ lines() {
+ return selectDiffLines(this.suggestion.diff_lines);
+ },
+ },
methods: {
applySuggestion(callback) {
this.$emit('apply', { suggestionId: this.suggestion.id, callback });
@@ -52,22 +47,11 @@ export default {
/>
<table class="mb-3 md-suggestion-diff js-syntax-highlight code">
<tbody>
- <!-- Old Line -->
- <tr class="line_holder old">
- <td class="diff-line-num old_line qa-old-diff-line-number old">{{ fromLine }}</td>
- <td class="diff-line-num new_line old"></td>
- <td class="line_content old">
- <span>{{ fromContent }}</span>
- </td>
- </tr>
- <!-- New Line(s) -->
- <tr v-for="(line, key) of newLines" :key="key" class="line_holder new">
- <td class="diff-line-num old_line new"></td>
- <td class="diff-line-num new_line qa-new-diff-line-number new">{{ line.lineNumber }}</td>
- <td class="line_content new">
- <span>{{ line.content }}</span>
- </td>
- </tr>
+ <suggestion-diff-row
+ v-for="(line, index) of lines"
+ :key="`${index}-${line.text}`"
+ :line="line"
+ />
</tbody>
</table>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
new file mode 100644
index 00000000000..cafd3a515ea
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
@@ -0,0 +1,32 @@
+<script>
+export default {
+ name: 'SuggestionDiffRow',
+ props: {
+ line: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ lineType() {
+ return this.line.type;
+ },
+ },
+};
+</script>
+
+<template>
+ <tr class="line_holder" :class="lineType">
+ <td class="diff-line-num old_line" :class="lineType">
+ {{ line.old_line }}
+ </td>
+ <td class="diff-line-num new_line" :class="lineType">
+ {{ line.new_line }}
+ </td>
+ <td class="line_content" :class="lineType">
+ <span v-if="line.text">{{ line.text }}</span>
+ <!-- TODO: replace this hack with zero-width whitespace when we have rich_text from BE -->
+ <span v-else>&#8203;</span>
+ </td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 177d78cb904..8d3705e1e4a 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -6,16 +6,6 @@ import Flash from '~/flash';
export default {
components: { SuggestionDiff },
props: {
- fromLine: {
- type: Number,
- required: false,
- default: 0,
- },
- fromContent: {
- type: String,
- required: false,
- default: '',
- },
lineType: {
type: String,
required: false,
@@ -71,41 +61,19 @@ export default {
suggestionElements.forEach((suggestionEl, i) => {
const suggestionParentEl = suggestionEl.parentElement;
- const newLines = this.extractNewLines(suggestionParentEl);
- const diffComponent = this.generateDiff(newLines, i);
+ const diffComponent = this.generateDiff(i);
diffComponent.$mount(suggestionParentEl);
});
this.isRendered = true;
},
- extractNewLines(suggestionEl) {
- // extracts the suggested lines from the markdown
- // calculates a line number for each line
-
- const newLines = suggestionEl.querySelectorAll('.line');
- const fromLine = this.suggestions.length ? this.suggestions[0].from_line : this.fromLine;
- const lines = [];
-
- newLines.forEach((line, i) => {
- const content = `${line.innerText}\n`;
- const lineNumber = fromLine + i;
- lines.push({ content, lineNumber });
- });
-
- return lines;
- },
- generateDiff(newLines, suggestionIndex) {
- // generates the diff <suggestion-diff /> component
- // all `suggestion` markdown will be swapped out by this component
-
+ generateDiff(suggestionIndex) {
const { suggestions, disabled, helpPagePath } = this;
const suggestion =
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
- const fromContent = suggestion.from_content || this.fromContent;
- const fromLine = suggestion.from_line || this.fromLine;
const SuggestionDiffComponent = Vue.extend(SuggestionDiff);
const suggestionDiff = new SuggestionDiffComponent({
- propsData: { newLines, fromLine, fromContent, disabled, suggestion, helpPagePath },
+ propsData: { disabled, suggestion, helpPagePath },
});
suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue
new file mode 100644
index 00000000000..1f3d248e991
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue
@@ -0,0 +1,40 @@
+<script>
+import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
+import $ from 'jquery';
+
+export default {
+ data() {
+ return {
+ width: 0,
+ height: 0,
+ };
+ },
+ beforeDestroy() {
+ this.contentResizeHandler.off('content.resize', this.debouncedResize);
+ window.removeEventListener('resize', this.debouncedResize);
+ },
+ created() {
+ this.debouncedResize = debounceByAnimationFrame(this.onResize);
+
+ // Handle when we explicictly trigger a custom resize event
+ this.contentResizeHandler = $(document).on('content.resize', this.debouncedResize);
+
+ // Handle window resize
+ window.addEventListener('resize', this.debouncedResize);
+ },
+ methods: {
+ onResize() {
+ // Slot dimensions
+ const { clientWidth, clientHeight } = this.$refs.chartWrapper;
+ this.width = clientWidth;
+ this.height = clientHeight;
+ },
+ },
+};
+</script>
+
+<template>
+ <div ref="chartWrapper">
+ <slot :width="width" :height="height"> </slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
index f66e81b1e08..9c258c4651f 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
@@ -75,6 +75,16 @@ export default {
required: false,
default: false,
},
+ enableScopedLabels: {
+ type: Boolean,
+ require: false,
+ default: false,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ require: false,
+ default: '#',
+ },
},
computed: {
hiddenInputName() {
@@ -123,7 +133,12 @@ export default {
@onValueClick="handleCollapsedValueClick"
/>
<dropdown-title :can-edit="canEdit" />
- <dropdown-value :labels="context.labels" :label-filter-base-path="labelFilterBasePath">
+ <dropdown-value
+ :labels="context.labels"
+ :label-filter-base-path="labelFilterBasePath"
+ :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
+ :enable-scoped-labels="enableScopedLabels"
+ >
<slot></slot>
</dropdown-value>
<div v-if="canEdit" class="selectbox js-selectbox" style="display: none;">
@@ -142,6 +157,8 @@ export default {
:namespace="namespace"
:labels="context.labels"
:show-extra-options="!showCreate"
+ :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
+ :enable-scoped-labels="enableScopedLabels"
/>
<div
class="dropdown-menu dropdown-select dropdown-menu-paging
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
index 498b507d11d..1eed8907bb7 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
@@ -31,6 +31,16 @@ export default {
type: Boolean,
required: true,
},
+ enableScopedLabels: {
+ type: Boolean,
+ require: false,
+ default: false,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ require: false,
+ default: '#',
+ },
},
computed: {
dropdownToggleText() {
@@ -61,6 +71,8 @@ export default {
:data-labels="labelsPath"
:data-namespace-path="namespace"
:data-show-any="showExtraOptions"
+ :data-scoped-labels="enableScopedLabels"
+ :data-scoped-labels-documentation-link="scopedLabelsDocumentationLink"
type="button"
class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal"
data-toggle="dropdown"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
index 6faf3fafad1..ddc488adbcb 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
@@ -1,9 +1,11 @@
<script>
-import tooltip from '~/vue_shared/directives/tooltip';
+import DropdownValueScopedLabel from './dropdown_value_scoped_label.vue';
+import DropdownValueRegularLabel from './dropdown_value_regular_label.vue';
export default {
- directives: {
- tooltip,
+ components: {
+ DropdownValueScopedLabel,
+ DropdownValueRegularLabel,
},
props: {
labels: {
@@ -14,6 +16,16 @@ export default {
type: String,
required: true,
},
+ enableScopedLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ required: false,
+ default: '#',
+ },
},
computed: {
isEmpty() {
@@ -30,6 +42,12 @@ export default {
backgroundColor: label.color,
};
},
+ scopedLabelsDescription({ description = '' }) {
+ return `<span class="font-weight-bold scoped-label-tooltip-title">Scoped label</span><br />${description}`;
+ },
+ showScopedLabels({ title = '' }) {
+ return this.enableScopedLabels && title.indexOf('::') !== -1;
+ },
},
};
</script>
@@ -44,17 +62,24 @@ export default {
<span v-if="isEmpty" class="text-secondary">
<slot>{{ __('None') }}</slot>
</span>
- <a v-for="label in labels" v-else :key="label.id" :href="labelFilterUrl(label)">
- <span
- v-tooltip
- :style="labelStyle(label)"
- :title="label.description"
- class="badge color-label"
- data-placement="bottom"
- data-container="body"
- >
- {{ label.title }}
- </span>
- </a>
+
+ <template v-for="label in labels" v-else>
+ <dropdown-value-scoped-label
+ v-if="showScopedLabels(label)"
+ :key="label.id"
+ :label="label"
+ :label-filter-url="labelFilterUrl(label)"
+ :label-style="labelStyle(label)"
+ :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
+ />
+
+ <dropdown-value-regular-label
+ v-else
+ :key="label.id"
+ :label="label"
+ :label-filter-url="labelFilterUrl(label)"
+ :label-style="labelStyle(label)"
+ />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue
new file mode 100644
index 00000000000..282b181f11e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlLink, GlTooltip } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlTooltip,
+ GlLink,
+ },
+ props: {
+ label: {
+ type: Object,
+ required: true,
+ },
+ labelStyle: {
+ type: Object,
+ required: true,
+ },
+ labelFilterUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <a ref="regularLabelRef" :href="labelFilterUrl">
+ <span :style="labelStyle" class="badge color-label">
+ {{ label.title }}
+ </span>
+ <gl-tooltip :target="() => $refs.regularLabelRef" placement="top" boundary="viewport">
+ {{ label.description }}
+ </gl-tooltip>
+ </a>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue
new file mode 100644
index 00000000000..ad5a86de166
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlLink, GlTooltip } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlTooltip,
+ GlLink,
+ },
+ props: {
+ label: {
+ type: Object,
+ required: true,
+ },
+ labelStyle: {
+ type: Object,
+ required: true,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ required: true,
+ },
+ labelFilterUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="d-inline-block position-relative scoped-label-wrapper">
+ <a :href="labelFilterUrl">
+ <span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label">
+ {{ label.title }}
+ </span>
+ <gl-tooltip :target="() => $refs.labelTitleRef" placement="top" boundary="viewport">
+ <span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span
+ ><br />
+ {{ label.description }}
+ </gl-tooltip>
+ </a>
+
+ <gl-link :href="scopedLabelsDocumentationLink" target="_blank" class="label scoped-label"
+ ><i class="fa fa-question-circle" :style="labelStyle"></i
+ ></gl-link>
+ </span>
+</template>
diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss
index 0d8e4afa76f..643b20c56bc 100644
--- a/app/assets/stylesheets/framework/callout.scss
+++ b/app/assets/stylesheets/framework/callout.scss
@@ -28,6 +28,10 @@
background-color: $red-100;
border-color: $red-200;
color: $red-700;
+
+ a {
+ color: $red-700;
+ }
}
.bs-callout-warning {
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index b90db135b4a..efcd35a2e0e 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -287,7 +287,7 @@
list-style: none;
padding: 0 1px;
- a,
+ a:not(.btn),
button,
.menu-item {
@include dropdown-link;
@@ -351,6 +351,10 @@
// Expects up to 3 digits on the badge
margin-right: 40px;
}
+
+ .dropdown-menu-content {
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
+ }
}
.droplab-dropdown {
diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss
index c36c15a85be..1c23c14c2de 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -204,8 +204,10 @@ label {
margin-top: #{$grid-size / 2};
}
-.gl-field-error {
+.gl-field-error,
+.invalid-feedback {
color: $red-500;
+ font-size: $gl-font-size;
}
.gl-show-field-errors {
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 02364180c35..ebc5226bf87 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -154,11 +154,34 @@
.swipe-wrap {
overflow: hidden;
- border-left: 1px solid $gl-gray-400;
+ border-right: 1px solid $gl-gray-400;
position: absolute;
display: block;
top: 13px;
right: 7px;
+
+ &.left-oriented {
+ /* only for commit view (different swipe viewer) */
+ border-right: 0;
+ border-left: 1px solid $gl-gray-400;
+ }
+ }
+
+ .frame {
+ top: 0;
+ right: 0;
+
+ &.old-diff {
+ /* only for commit / compare view */
+ position: absolute;
+ }
+
+ &.deleted {
+ margin: 0;
+ display: block;
+ top: 13px;
+ right: 7px;
+ }
}
.swipe-bar {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 6415d902ca6..9be3f8138a0 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -110,6 +110,16 @@
font-size: 0;
margin-bottom: -5px;
}
+
+ .scoped-label-wrapper {
+ .color-label {
+ padding-right: $gl-padding-24;
+ }
+
+ .scoped-label {
+ right: 12px;
+ }
+ }
}
.right-sidebar {
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index 6f98b4f7f13..e7fd7fab32b 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -402,3 +402,39 @@
.priority-labels-empty-state .svg-content img {
max-width: $priority-label-empty-state-width;
}
+
+.scoped-label-tooltip-title {
+ color: $indigo-300;
+}
+
+.scoped-label-wrapper {
+ &.label-link .color-label a {
+ color: inherit;
+ }
+
+ .color-label {
+ padding-right: $gl-padding-24;
+ }
+
+ .scoped-label {
+ position: absolute;
+ top: 4px;
+ right: 8px;
+ padding: 0;
+ margin: 0;
+ line-height: $gl-line-height;
+ }
+}
+
+// Label inside title of Delete Label Modal
+.modal-header .page-title {
+ .scoped-label-wrapper {
+ .scoped-label {
+ line-height: 20px;
+ }
+
+ span.color-label {
+ padding-right: $gl-padding-24;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 7f8b8ea8100..f7ebd84dc1c 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -595,7 +595,6 @@
color: $gl-text-color;
}
-
.git-merge-container {
justify-content: space-between;
flex: 1;
@@ -805,7 +804,8 @@
}
}
-.merge-request-tabs-holder {
+.merge-request-tabs-holder,
+.epic-tabs-holder {
top: $header-height;
z-index: 250;
background-color: $white-light;
@@ -823,11 +823,6 @@
@include media-breakpoint-down(xs) {
right: 0;
}
-
- .merge-request-tabs-container {
- padding-left: $gl-padding;
- padding-right: $gl-padding;
- }
}
.nav-links {
@@ -835,11 +830,21 @@
}
}
-.with-performance-bar .merge-request-tabs-holder {
- top: $header-height + $performance-bar-height;
+.merge-request-tabs-holder.affix .merge-request-tabs-container,
+.epic-tabs-holder.affix .epic-tabs-container {
+ padding-left: $gl-padding;
+ padding-right: $gl-padding;
}
-.merge-request-tabs {
+.with-performance-bar {
+ .merge-request-tabs-holder,
+ .epic-tabs-holder {
+ top: $header-height + $performance-bar-height;
+ }
+}
+
+.merge-request-tabs,
+.epic-tabs {
display: flex;
flex-wrap: nowrap;
margin-bottom: 0;
@@ -847,7 +852,8 @@
}
.limit-container-width {
- .merge-request-tabs-container {
+ .merge-request-tabs-container,
+ .epic-tabs-container {
max-width: $limited-layout-width;
margin-left: auto;
margin-right: auto;
@@ -860,7 +866,8 @@
}
}
-.merge-request-tabs-container {
+.merge-request-tabs-container,
+.epic-tabs-container {
display: flex;
justify-content: space-between;
@@ -878,10 +885,9 @@
}
.limit-container-width:not(.container-limited) {
- .merge-request-tabs-holder:not(.affix) {
- .merge-request-tabs-container {
- max-width: $limited-layout-width - ($gl-padding * 2);
- }
+ .merge-request-tabs-holder:not(.affix) .merge-request-tabs-container,
+ .epic-tabs-holder:not(.affix) .epic-tabs-container {
+ max-width: $limited-layout-width - ($gl-padding * 2);
}
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 0c334e919de..fd07415a52f 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -44,7 +44,6 @@ $note-form-margin-left: 72px;
border: 1px solid $border-color;
border-radius: $border-radius-default;
margin: $gl-padding 0;
- overflow: auto;
&.system-note,
&.note-form {
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 792c618fd40..79558b8604f 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -67,6 +67,10 @@
}
}
+.classification-label {
+ background-color: $red-500;
+}
+
.toggle-wrapper {
margin-top: 5px;
}
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index ab792cf7403..b681949ab36 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -124,7 +124,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
end
def visible_application_setting_attributes
- ApplicationSettingsHelper.visible_attributes + [
+ [
+ *::ApplicationSettingsHelper.visible_attributes,
+ *::ApplicationSettingsHelper.external_authorization_service_attributes,
:domain_blacklist_file,
disabled_oauth_sign_in_sources: [],
import_sources: [],
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 8ef3b6502df..85aeecbf90b 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -7,6 +7,9 @@ module IssuableActions
included do
before_action :authorize_destroy_issuable!, only: :destroy
before_action :authorize_admin_issuable!, only: :bulk_update
+ before_action only: :show do
+ push_frontend_feature_flag(:scoped_labels, default_enabled: true)
+ end
end
def permitted_keys
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index c529aabf797..6d6e0cc6c7f 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -100,6 +100,7 @@ module IssuableCollections
if @project
options[:project_id] = @project.id
+ options[:attempt_project_search_optimizations] = true
elsif @group
options[:group_id] = @group.id
options[:include_subgroups] = true
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
index f72d25fc54c..2a9729b6ffd 100644
--- a/app/controllers/concerns/preview_markdown.rb
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -20,7 +20,7 @@ module PreviewMarkdown
body: view_context.markdown(result[:text], markdown_params),
references: {
users: result[:users],
- suggestions: result[:suggestions],
+ suggestions: SuggestionSerializer.new.represent_diff(result[:suggestions]),
commands: view_context.markdown(result[:commands])
}
}
diff --git a/app/controllers/concerns/project_unauthorized.rb b/app/controllers/concerns/project_unauthorized.rb
index f59440dbc59..d42363b8b17 100644
--- a/app/controllers/concerns/project_unauthorized.rb
+++ b/app/controllers/concerns/project_unauthorized.rb
@@ -1,10 +1,21 @@
# frozen_string_literal: true
module ProjectUnauthorized
- extend ActiveSupport::Concern
-
- # EE would override this
def project_unauthorized_proc
- # no-op
+ lambda do |project|
+ if project
+ label = project.external_authorization_classification_label
+ rejection_reason = nil
+
+ unless ::Gitlab::ExternalAuthorization.access_allowed?(current_user, label)
+ rejection_reason = ::Gitlab::ExternalAuthorization.rejection_reason(current_user, label)
+ rejection_reason ||= _('External authorization denied access to this project')
+ end
+
+ if rejection_reason
+ access_denied!(rejection_reason)
+ end
+ end
+ end
end
end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 75329b05a6f..1a97b39d3ae 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -46,7 +46,10 @@ class DashboardController < Dashboard::ApplicationController
end
def check_filters_presence!
- @no_filters_set = finder_type.scalar_params.none? { |k| params.key?(k) }
+ no_scalar_filters_set = finder_type.scalar_params.none? { |k| params.key?(k) }
+ no_array_filters_set = finder_type.array_params.none? { |k, _| params.key?(k) }
+
+ @no_filters_set = no_scalar_filters_set && no_array_filters_set
return unless @no_filters_set
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 0192b1c253e..87b8ef03313 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -124,8 +124,8 @@ class GroupsController < Groups::ApplicationController
flash[:notice] = "Group '#{@group.name}' was successfully transferred."
redirect_to group_path(@group)
else
- flash.now[:alert] = service.error
- render :edit
+ flash[:alert] = service.error
+ redirect_to edit_group_path(@group)
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index 6504fd6c08a..781eac7f080 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -88,4 +88,10 @@ class Projects::ApplicationController < ApplicationController
def check_issues_available!
return render_404 unless @project.feature_available?(:issues, current_user)
end
+
+ def allow_gitaly_ref_name_caching
+ ::Gitlab::GitalyClient.allow_ref_name_caching do
+ yield
+ end
+ end
end
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index 2510a31c9b3..a49ede04de7 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -14,6 +14,8 @@ class Projects::CommitsController < Projects::ApplicationController
before_action :validate_ref!, except: :commits_root
before_action :set_commits, except: :commits_root
+ around_action :allow_gitaly_ref_name_caching
+
def commits_root
redirect_to project_commits_path(@project, @project.default_branch)
end
diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb
new file mode 100644
index 00000000000..fd3320637b0
--- /dev/null
+++ b/app/controllers/projects/environments/prometheus_api_controller.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class Projects::Environments::PrometheusApiController < Projects::ApplicationController
+ before_action :authorize_read_prometheus!
+ before_action :environment
+
+ def proxy
+ result = Prometheus::ProxyService.new(
+ environment,
+ request.method,
+ params[:proxy_path],
+ params.permit!
+ ).execute
+
+ if result.nil?
+ return render status: :accepted, json: {
+ status: _('processing'),
+ message: _('Not ready yet. Try again later.')
+ }
+ end
+
+ if result[:status] == :success
+ render status: result[:http_status], json: result[:body]
+ else
+ render(
+ status: result[:http_status] || :bad_request,
+ json: { status: result[:status], message: result[:message] }
+ )
+ end
+ end
+
+ private
+
+ def environment
+ @environment ||= project.environments.find(params[:id])
+ end
+end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index e9cd475a199..301449cfa90 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -10,6 +10,9 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics]
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index]
+ before_action only: [:metrics, :additional_metrics] do
+ push_frontend_feature_flag(:metrics_time_window)
+ end
def index
@environments = project.environments
@@ -114,7 +117,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
terminal = environment.terminals.try(:first)
if terminal
set_workhorse_internal_api_content_type
- render json: Gitlab::Workhorse.terminal_websocket(terminal)
+ render json: Gitlab::Workhorse.channel_websocket(terminal)
else
render html: 'Not found', status: :not_found
end
@@ -146,7 +149,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def additional_metrics
respond_to do |format|
format.json do
- additional_metrics = environment.additional_metrics || {}
+ additional_metrics = environment.additional_metrics(*metrics_params) || {}
render json: additional_metrics, status: additional_metrics.any? ? :ok : :no_content
end
@@ -186,6 +189,13 @@ class Projects::EnvironmentsController < Projects::ApplicationController
@environment ||= project.environments.find(params[:id])
end
+ def metrics_params
+ return unless Feature.enabled?(:metrics_time_window, project)
+ return unless params[:start].present? || params[:end].present?
+
+ params.require([:start, :end]).values_at(:start, :end)
+ end
+
def search_environment_names
return [] unless params[:query]
diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb
index 55d5fce9214..85628dd32d8 100644
--- a/app/controllers/projects/git_http_client_controller.rb
+++ b/app/controllers/projects/git_http_client_controller.rb
@@ -98,10 +98,8 @@ class Projects::GitHttpClientController < Projects::ApplicationController
def repo_type
parse_repo_path unless defined?(@repo_type)
- # When there a project did not exist, the parsed repo_type would be empty.
- # In that case, we want to continue with a regular project repository. As we
- # could create the project if the user pushing is allowed to do so.
- @repo_type || Gitlab::GlRepository::PROJECT
+
+ @repo_type
end
def handle_basic_authentication(login, password)
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 35cc32d3e63..2a4933e7bc2 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -157,7 +157,7 @@ class Projects::JobsController < Projects::ApplicationController
# GET .../terminal.ws : implemented in gitlab-workhorse
def terminal_websocket_authorize
set_workhorse_internal_api_content_type
- render json: Gitlab::Workhorse.terminal_websocket(@build.terminal_specification)
+ render json: Gitlab::Workhorse.channel_websocket(@build.terminal_specification)
end
private
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 34cb0416965..39ba2a651d4 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -16,6 +16,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
+ around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
+
def index
@merge_requests = @issuables
@@ -315,9 +317,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def serializer
- ::Gitlab::GitalyClient.allow_ref_name_caching do
- MergeRequestSerializer.new(current_user: current_user, project: merge_request.project)
- end
+ MergeRequestSerializer.new(current_user: current_user, project: merge_request.project)
end
def define_edit_vars
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index c306ba3ffcf..22c4b8eef1f 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -8,6 +8,8 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_create_pipeline!, only: [:new, :create]
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
+ around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
+
wrap_parameters Ci::Pipeline
POLLING_INTERVAL = 10_000
@@ -148,12 +150,10 @@ class Projects::PipelinesController < Projects::ApplicationController
private
def serialize_pipelines
- ::Gitlab::GitalyClient.allow_ref_name_caching do
- PipelineSerializer
- .new(project: @project, current_user: @current_user)
- .with_pagination(request, response)
- .represent(@pipelines, disable_coverage: true, preload: true)
- end
+ PipelineSerializer
+ .new(project: @project, current_user: @current_user)
+ .with_pagination(request, response)
+ .represent(@pipelines, disable_coverage: true, preload: true)
end
def render_show
diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb
index 4eeaeb860ee..3b4215b766e 100644
--- a/app/controllers/projects/repositories_controller.rb
+++ b/app/controllers/projects/repositories_controller.rb
@@ -23,7 +23,7 @@ class Projects::RepositoriesController < Projects::ApplicationController
append_sha = false if @filename == shortname
end
- send_git_archive @repository, ref: @ref, format: params[:format], append_sha: append_sha
+ send_git_archive @repository, ref: @ref, path: params[:path], format: params[:format], append_sha: append_sha
rescue => ex
logger.error("#{self.class.name}: #{ex}")
git_not_found!
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index da2420633ef..88910c91763 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -16,7 +16,10 @@ class Projects::WikisController < Projects::ApplicationController
end
def pages
- @wiki_pages = Kaminari.paginate_array(@project_wiki.pages).page(params[:page])
+ @wiki_pages = Kaminari.paginate_array(
+ @project_wiki.pages(sort: params[:sort], direction: params[:direction])
+ ).page(params[:page])
+
@wiki_entries = WikiPage.group_by_directory(@wiki_pages)
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index f76e6663995..632be29df72 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -26,6 +26,8 @@ class ProjectsController < Projects::ApplicationController
before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export]
before_action :event_filter, only: [:show, :activity]
+ around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
+
layout :determine_layout
def index
@@ -343,6 +345,7 @@ class ProjectsController < Projects::ApplicationController
:container_registry_enabled,
:default_branch,
:description,
+ :external_authorization_classification_label,
:import_url,
:issues_tracker,
:issues_tracker_id,
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index 6eab8c5ee51..64c88505a16 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -53,7 +53,6 @@ class IssuableFinder
assignee_username
author_id
author_username
- label_name
milestone_title
my_reaction_emoji
search
@@ -84,7 +83,7 @@ class IssuableFinder
# https://www.postgresql.org/docs/current/static/queries-with.html
items = by_search(items)
- items = sort(items) unless use_cte_for_count?
+ items = sort(items)
items
end
@@ -92,7 +91,6 @@ class IssuableFinder
def filter_items(items)
items = by_project(items)
items = by_group(items)
- items = by_subquery(items)
items = by_scope(items)
items = by_created_at(items)
items = by_updated_at(items)
@@ -132,10 +130,12 @@ class IssuableFinder
# This does not apply when we are using a CTE for the search, as the labels
# GROUP BY is inside the subquery in that case, so we set labels_count to 1.
#
- # We always use CTE when searching in Groups if the feature flag is enabled,
- # but never when searching in Projects.
+ # Groups and projects have separate feature flags to suggest the use
+ # of a CTE. The CTE will not be used if the sort doesn't support it,
+ # but will always be used for the counts here as we ignore sorting
+ # anyway.
labels_count = label_names.any? ? label_names.count : 1
- labels_count = 1 if use_cte_for_count?
+ labels_count = 1 if use_cte_for_search?
finder.execute.reorder(nil).group(:state).count.each do |key, value|
counts[count_key(key)] += value / labels_count
@@ -309,15 +309,14 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
- def use_subquery_for_search?
- strong_memoize(:use_subquery_for_search) do
- !force_cte? && attempt_group_search_optimizations?
- end
- end
+ def use_cte_for_search?
+ strong_memoize(:use_cte_for_search) do
+ next false unless search
+ next false unless Gitlab::Database.postgresql?
+ # Only simple unsorted & simple sorts can use CTE
+ next false if params[:sort].present? && !params[:sort].in?(klass.simple_sorts.keys)
- def use_cte_for_count?
- strong_memoize(:use_cte_for_count) do
- force_cte? && attempt_group_search_optimizations?
+ attempt_group_search_optimizations? || attempt_project_search_optimizations?
end
end
@@ -332,12 +331,15 @@ class IssuableFinder
end
def attempt_group_search_optimizations?
- search &&
- Gitlab::Database.postgresql? &&
- params[:attempt_group_search_optimizations] &&
+ params[:attempt_group_search_optimizations] &&
Feature.enabled?(:attempt_group_search_optimizations, default_enabled: true)
end
+ def attempt_project_search_optimizations?
+ params[:attempt_project_search_optimizations] &&
+ Feature.enabled?(:attempt_project_search_optimizations)
+ end
+
def count_key(value)
Array(value).last.to_sym
end
@@ -408,20 +410,11 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
- # Wrap projects and groups in a subquery if the conditions are met.
- def by_subquery(items)
- if use_subquery_for_search?
- klass.where(id: items.select(:id)) # rubocop: disable CodeReuse/ActiveRecord
- else
- items
- end
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def by_search(items)
return items unless search
- if use_cte_for_count?
+ if use_cte_for_search?
cte = Gitlab::SQL::RecursiveCTE.new(klass.table_name)
cte << items
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index 84689ff5dc7..29947bc94d5 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -40,7 +40,8 @@ class MergeRequestsFinder < IssuableFinder
items = by_commit(super)
items = by_source_branch(items)
items = by_wip(items)
- by_target_branch(items)
+ items = by_target_branch(items)
+ by_source_project_id(items)
end
private
@@ -74,6 +75,16 @@ class MergeRequestsFinder < IssuableFinder
items.where(target_branch: target_branch)
end
+ def source_project_id
+ @source_project_id ||= params[:source_project_id].presence
+ end
+
+ def by_source_project_id(items)
+ return items unless source_project_id
+
+ items.where(source_project_id: source_project_id)
+ end
+
def by_wip(items)
if params[:wip] == 'yes'
items.where(wip_match(items.arel_table))
diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb
index 06d26309b5b..53efd9042b1 100644
--- a/app/graphql/gitlab_schema.rb
+++ b/app/graphql/gitlab_schema.rb
@@ -1,13 +1,44 @@
# frozen_string_literal: true
class GitlabSchema < GraphQL::Schema
+ # Took our current most complicated query in use, issues.graphql,
+ # with a complexity of 19, and added a 20 point buffer to it.
+ # These values will evolve over time.
+ DEFAULT_MAX_COMPLEXITY = 40
+ AUTHENTICATED_COMPLEXITY = 50
+ ADMIN_COMPLEXITY = 60
+
use BatchLoader::GraphQL
use Gitlab::Graphql::Authorize
use Gitlab::Graphql::Present
use Gitlab::Graphql::Connections
+ use Gitlab::Graphql::Tracing
+
+ query_analyzer Gitlab::Graphql::QueryAnalyzers::LogQueryComplexity.analyzer
query(Types::QueryType)
default_max_page_size 100
+
+ max_complexity DEFAULT_MAX_COMPLEXITY
+
mutation(Types::MutationType)
+
+ def self.execute(query_str = nil, **kwargs)
+ kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context])
+
+ super(query_str, **kwargs)
+ end
+
+ def self.max_query_complexity(ctx)
+ current_user = ctx&.fetch(:current_user, nil)
+
+ if current_user&.admin
+ ADMIN_COMPLEXITY
+ elsif current_user
+ AUTHENTICATED_COMPLEXITY
+ else
+ DEFAULT_MAX_COMPLEXITY
+ end
+ end
end
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index 2b2ea64c00b..8c8b8a82d3e 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -3,5 +3,14 @@
module Types
class BaseField < GraphQL::Schema::Field
prepend Gitlab::Graphql::Authorize
+
+ DEFAULT_COMPLEXITY = 1
+
+ def initialize(*args, **kwargs, &block)
+ # complexity is already defaulted to 1, but let's make it explicit
+ kwargs[:complexity] ||= DEFAULT_COMPLEXITY
+
+ super(*args, **kwargs, &block)
+ end
end
end
diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb
index 18696293b97..de7d6570a3e 100644
--- a/app/graphql/types/ci/pipeline_type.rb
+++ b/app/graphql/types/ci/pipeline_type.rb
@@ -3,10 +3,12 @@
module Types
module Ci
class PipelineType < BaseObject
- expose_permissions Types::PermissionTypes::Ci::Pipeline
-
graphql_name 'Pipeline'
+ authorize :read_pipeline
+
+ expose_permissions Types::PermissionTypes::Ci::Pipeline
+
field :id, GraphQL::ID_TYPE, null: false
field :iid, GraphQL::ID_TYPE, null: false
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 5ad3ea52930..adb137dfee3 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -2,10 +2,12 @@
module Types
class IssueType < BaseObject
- expose_permissions Types::PermissionTypes::Issue
-
graphql_name 'Issue'
+ authorize :read_issue
+
+ expose_permissions Types::PermissionTypes::Issue
+
present_using IssuePresenter
field :iid, GraphQL::ID_TYPE, null: false
@@ -15,16 +17,14 @@ module Types
field :author, Types::UserType,
null: false,
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find },
- authorize: :read_user
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find }
field :assignees, Types::UserType.connection_type, null: true
field :labels, Types::LabelType.connection_type, null: true
field :milestone, Types::MilestoneType,
null: true,
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find },
- authorize: :read_milestone
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find }
field :due_date, Types::TimeType, null: true
field :confidential, GraphQL::BOOLEAN_TYPE, null: false
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 1ed27a14e33..120ffe0dfde 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -2,12 +2,14 @@
module Types
class MergeRequestType < BaseObject
+ graphql_name 'MergeRequest'
+
+ authorize :read_merge_request
+
expose_permissions Types::PermissionTypes::MergeRequest
present_using MergeRequestPresenter
- graphql_name 'MergeRequest'
-
field :id, GraphQL::ID_TYPE, null: false
field :iid, GraphQL::ID_TYPE, null: false
field :title, GraphQL::STRING_TYPE, null: false
@@ -48,7 +50,7 @@ module Types
field :downvotes, GraphQL::INT_TYPE, null: false
field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false
- field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline, authorize: :read_pipeline
+ field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline
field :pipelines, Types::Ci::PipelineType.connection_type,
resolver: Resolvers::MergeRequestPipelinesResolver
end
diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb
index af31b572c9a..2772fbec86f 100644
--- a/app/graphql/types/milestone_type.rb
+++ b/app/graphql/types/milestone_type.rb
@@ -4,6 +4,8 @@ module Types
class MilestoneType < BaseObject
graphql_name 'Milestone'
+ authorize :read_milestone
+
field :description, GraphQL::STRING_TYPE, null: true
field :title, GraphQL::STRING_TYPE, null: false
field :state, GraphQL::STRING_TYPE, null: false
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index b96c2f3afb2..fbb4eddd13c 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -2,10 +2,12 @@
module Types
class ProjectType < BaseObject
- expose_permissions Types::PermissionTypes::Project
-
graphql_name 'Project'
+ authorize :read_project
+
+ expose_permissions Types::PermissionTypes::Project
+
field :id, GraphQL::ID_TYPE, null: false
field :full_path, GraphQL::ID_TYPE, null: false
@@ -67,14 +69,12 @@ module Types
field :merge_requests,
Types::MergeRequestType.connection_type,
null: true,
- resolver: Resolvers::MergeRequestsResolver,
- authorize: :read_merge_request
+ resolver: Resolvers::MergeRequestsResolver
field :merge_request,
Types::MergeRequestType,
null: true,
- resolver: Resolvers::MergeRequestsResolver.single,
- authorize: :read_merge_request
+ resolver: Resolvers::MergeRequestsResolver.single
field :issues,
Types::IssueType.connection_type,
@@ -88,7 +88,7 @@ module Types
field :pipelines,
Types::Ci::PipelineType.connection_type,
- null: false,
+ null: true,
resolver: Resolvers::ProjectPipelinesResolver
end
end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 472fe5d6ec2..0f655ab9d03 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -7,8 +7,7 @@ module Types
field :project, Types::ProjectType,
null: true,
resolver: Resolvers::ProjectResolver,
- description: "Find a project",
- authorize: :read_project
+ description: "Find a project"
field :metadata, Types::MetadataType,
null: true,
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
index a13e65207df..6b53554314b 100644
--- a/app/graphql/types/user_type.rb
+++ b/app/graphql/types/user_type.rb
@@ -4,6 +4,8 @@ module Types
class UserType < BaseObject
graphql_name 'User'
+ authorize :read_user
+
present_using UserPresenter
field :name, GraphQL::STRING_TYPE, null: false
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index e635f608237..50b5edf7da4 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -119,6 +119,39 @@ module ApplicationSettingsHelper
options_for_select(options, selected)
end
+ def external_authorization_description
+ _("If enabled, access to projects will be validated on an external service"\
+ " using their classification label.")
+ end
+
+ def external_authorization_timeout_help_text
+ _("Time in seconds GitLab will wait for a response from the external "\
+ "service. When the service does not respond in time, access will be "\
+ "denied.")
+ end
+
+ def external_authorization_url_help_text
+ _("When leaving the URL blank, classification labels can still be "\
+ "specified without disabling cross project features or performing "\
+ "external authorization checks.")
+ end
+
+ def external_authorization_client_certificate_help_text
+ _("The X509 Certificate to use when mutual TLS is required to communicate "\
+ "with the external authorization service. If left blank, the server "\
+ "certificate is still validated when accessing over HTTPS.")
+ end
+
+ def external_authorization_client_key_help_text
+ _("The private key to use when a client certificate is provided. This value "\
+ "is encrypted at rest.")
+ end
+
+ def external_authorization_client_pass_help_text
+ _("The passphrase required to decrypt the private key. This is optional "\
+ "and the value is encrypted at rest.")
+ end
+
def visible_attributes
[
:admin_notification_email,
@@ -237,6 +270,18 @@ module ApplicationSettingsHelper
]
end
+ def external_authorization_service_attributes
+ [
+ :external_auth_client_cert,
+ :external_auth_client_key,
+ :external_auth_client_key_pass,
+ :external_authorization_service_default_label,
+ :external_authorization_service_enabled,
+ :external_authorization_service_timeout,
+ :external_authorization_service_url
+ ]
+ end
+
def expanded_by_default?
Rails.env.test?
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index d6fff1c36da..7e631053b54 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -77,7 +77,7 @@ module BlobHelper
project,
ref,
path,
- label: "Replace",
+ label: _("Replace"),
action: "replace",
btn_class: "default",
modal_type: "upload"
@@ -89,7 +89,7 @@ module BlobHelper
project,
ref,
path,
- label: "Delete",
+ label: _("Delete"),
action: "delete",
btn_class: "remove",
modal_type: "remove"
@@ -101,14 +101,14 @@ module BlobHelper
end
def leave_edit_message
- "Leave edit mode?\nAll unsaved changes will be lost."
+ _("Leave edit mode? All unsaved changes will be lost.")
end
def editing_preview_title(filename)
if Gitlab::MarkupHelper.previewable?(filename)
- 'Preview'
+ _('Preview')
else
- 'Preview changes'
+ _('Preview changes')
end
end
@@ -201,14 +201,14 @@ module BlobHelper
return if blob.empty?
return if blob.binary? || blob.stored_externally?
- title = 'Open raw'
+ title = _('Open raw')
link_to icon('file-code-o'), blob_raw_path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' }
end
def download_blob_button(blob)
return if blob.empty?
- title = 'Download'
+ title = _('Download')
link_to sprite_icon('download'), blob_raw_path(inline: false), download: @path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' }
end
diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb
index 3c8caec3fe5..a5fe6bb8f07 100644
--- a/app/helpers/builds_helper.rb
+++ b/app/helpers/builds_helper.rb
@@ -4,12 +4,12 @@ module BuildsHelper
def build_summary(build, skip: false)
if build.has_trace?
if skip
- link_to "View job trace", pipeline_job_url(build.pipeline, build)
+ link_to _("View job trace"), pipeline_job_url(build.pipeline, build)
else
build.trace.html(last_lines: 10).html_safe
end
else
- "No job trace"
+ _("No job trace")
end
end
@@ -31,7 +31,7 @@ module BuildsHelper
def build_failed_issue_options
{
- title: "Job Failed ##{@build.id}",
+ title: _("Job Failed #%{build_id}") % { build_id: @build.id },
description: project_job_url(@project, @build)
}
end
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index 494c754e7d5..03adbfa204f 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -21,7 +21,7 @@ module ButtonHelper
# See http://clipboardjs.com/#usage
def clipboard_button(data = {})
css_class = data[:class] || 'btn-clipboard btn-transparent'
- title = data[:title] || 'Copy to clipboard'
+ title = data[:title] || _('Copy to clipboard')
button_text = data[:button_text] || ''
hide_tooltip = data[:hide_tooltip] || false
hide_button_icon = data[:hide_button_icon] || false
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 5705ee54cee..8b3d270e873 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -4,8 +4,7 @@ module FormHelper
def form_errors(model, type: 'form')
return unless model.errors.any?
- pluralized = 'error'.pluralize(model.errors.count)
- headline = "The #{type} contains the following #{pluralized}:"
+ headline = n_('The %{type} contains the following error:', 'The %{type} contains the following errors:', model.errors.count) % { type: type }
content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do
content_tag(:h4, headline) <<
@@ -24,7 +23,7 @@ module FormHelper
title: 'Select assignee',
filter: true,
dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee',
- placeholder: 'Search users',
+ placeholder: _('Search users'),
data: {
first_user: current_user&.username,
null_user: true,
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index bd53add80ca..e91e8f85515 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -46,7 +46,7 @@ module LabelsHelper
if block_given?
link_to link, class: css_class, &block
else
- link_to render_colored_label(label, tooltip: tooltip), link, class: css_class
+ render_label(label, tooltip: tooltip, link: link, css: css_class)
end
end
@@ -78,19 +78,33 @@ module LabelsHelper
end
end
- def render_colored_label(label, label_suffix = '', tooltip: true)
+ def render_label(label, tooltip: true, link: nil, css: nil)
+ # if scoped label is used then EE wraps label tag with scoped label
+ # doc link
+ html = render_colored_label(label, tooltip: tooltip)
+ html = link_to(html, link, class: css) if link
+
+ html
+ end
+
+ def render_colored_label(label, label_suffix: '', tooltip: true, title: nil)
text_color = text_color_for_bg(label.color)
+ title ||= tooltip ? label_tooltip_title(label) : ''
# Intentionally not using content_tag here so that this method can be called
# by LabelReferenceFilter
span = %(<span class="badge color-label #{"has-tooltip" if tooltip}" ) +
- %(style="background-color: #{label.color}; color: #{text_color}" ) +
- %(title="#{escape_once(label.description)}" data-container="body">) +
+ %(data-html="true" style="background-color: #{label.color}; color: #{text_color}" ) +
+ %(title="#{escape_once(title)}" data-container="body">) +
%(#{escape_once(label.name)}#{label_suffix}</span>)
span.html_safe
end
+ def label_tooltip_title(label)
+ label.description
+ end
+
def suggested_colors
[
'#0033CC',
@@ -181,8 +195,8 @@ module LabelsHelper
def label_deletion_confirm_text(label)
case label
- when GroupLabel then 'Remove this label? This will affect all projects within the group. Are you sure?'
- when ProjectLabel then 'Remove this label? Are you sure?'
+ when GroupLabel then _('Remove this label? This will affect all projects within the group. Are you sure?')
+ when ProjectLabel then _('Remove this label? Are you sure?')
end
end
@@ -231,6 +245,37 @@ module LabelsHelper
labels.sort_by(&:title)
end
+ def label_dropdown_data(project, opts = {})
+ {
+ toggle: "dropdown",
+ field_name: opts[:field_name] || "label_name[]",
+ show_no: "true",
+ show_any: "true",
+ project_id: project&.try(:id),
+ namespace_path: project&.try(:namespace)&.try(:full_path),
+ project_path: project&.try(:path)
+ }.merge(opts)
+ end
+
+ def sidebar_label_dropdown_data(issuable_type, issuable_sidebar)
+ label_dropdown_data(nil, {
+ default_label: "Labels",
+ field_name: "#{issuable_type}[label_names][]",
+ ability_name: issuable_type,
+ namespace_path: issuable_sidebar[:namespace_path],
+ project_path: issuable_sidebar[:project_path],
+ issue_update: issuable_sidebar[:issuable_json_path],
+ labels: issuable_sidebar[:project_labels_path],
+ display: 'static'
+ })
+ end
+
+ def label_from_hash(hash)
+ klass = hash[:group_id] ? GroupLabel : ProjectLabel
+
+ klass.new(hash.slice(:color, :description, :title, :group_id, :project_id))
+ end
+
# Required for Banzai::Filter::LabelReferenceFilter
- module_function :render_colored_label, :text_color_for_bg, :escape_once
+ module_function :render_colored_label, :text_color_for_bg, :escape_once, :label_tooltip_title
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index f2abb241753..2ac90eb8d9f 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -299,6 +299,20 @@ module ProjectsHelper
}.to_json
end
+ def directory?
+ @path.present?
+ end
+
+ def external_classification_label_help_message
+ default_label = ::Gitlab::CurrentSettings.current_application_settings
+ .external_authorization_service_default_label
+
+ s_(
+ "ExternalAuthorizationService|When no classification label is set the "\
+ "default label `%{default_label}` will be used."
+ ) % { default_label: default_label }
+ end
+
private
def get_project_nav_tabs(project, current_user)
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 69520e33774..a62c00df60b 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -30,7 +30,7 @@ module SearchHelper
to = collection.offset_value + collection.to_a.size
count = collection.total_count
- "Showing #{from} - #{to} of #{count} #{scope.humanize(capitalize: false)} for \"#{term}\""
+ s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for \"%{term}\"") % { from: from, to: to, count: count, scope: scope.humanize(capitalize: false), term: term }
end
def find_project_for_result_blob(projects, result)
@@ -59,31 +59,31 @@ module SearchHelper
# Autocomplete results for various settings pages
def default_autocomplete
[
- { category: "Settings", label: "User settings", url: profile_path },
- { category: "Settings", label: "SSH Keys", url: profile_keys_path },
- { category: "Settings", label: "Dashboard", url: root_path }
+ { category: "Settings", label: _("User settings"), url: profile_path },
+ { category: "Settings", label: _("SSH Keys"), url: profile_keys_path },
+ { category: "Settings", label: _("Dashboard"), url: root_path }
]
end
# Autocomplete results for settings pages, for admins
def default_autocomplete_admin
[
- { category: "Settings", label: "Admin Section", url: admin_root_path }
+ { category: "Settings", label: _("Admin Section"), url: admin_root_path }
]
end
# Autocomplete results for internal help pages
def help_autocomplete
[
- { category: "Help", label: "API Help", url: help_page_path("api/README") },
- { category: "Help", label: "Markdown Help", url: help_page_path("user/markdown") },
- { category: "Help", label: "Permissions Help", url: help_page_path("user/permissions") },
- { category: "Help", label: "Public Access Help", url: help_page_path("public_access/public_access") },
- { category: "Help", label: "Rake Tasks Help", url: help_page_path("raketasks/README") },
- { category: "Help", label: "SSH Keys Help", url: help_page_path("ssh/README") },
- { category: "Help", label: "System Hooks Help", url: help_page_path("system_hooks/system_hooks") },
- { category: "Help", label: "Webhooks Help", url: help_page_path("user/project/integrations/webhooks") },
- { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") }
+ { category: "Help", label: _("API Help"), url: help_page_path("api/README") },
+ { category: "Help", label: _("Markdown Help"), url: help_page_path("user/markdown") },
+ { category: "Help", label: _("Permissions Help"), url: help_page_path("user/permissions") },
+ { category: "Help", label: _("Public Access Help"), url: help_page_path("public_access/public_access") },
+ { category: "Help", label: _("Rake Tasks Help"), url: help_page_path("raketasks/README") },
+ { category: "Help", label: _("SSH Keys Help"), url: help_page_path("ssh/README") },
+ { category: "Help", label: _("System Hooks Help"), url: help_page_path("system_hooks/system_hooks") },
+ { category: "Help", label: _("Webhooks Help"), url: help_page_path("user/project/integrations/webhooks") },
+ { category: "Help", label: _("Workflow Help"), url: help_page_path("workflow/README") }
]
end
@@ -93,16 +93,16 @@ module SearchHelper
ref = @ref || @project.repository.root_ref
[
- { category: "In this project", label: "Files", url: project_tree_path(@project, ref) },
- { category: "In this project", label: "Commits", url: project_commits_path(@project, ref) },
- { category: "In this project", label: "Network", url: project_network_path(@project, ref) },
- { category: "In this project", label: "Graph", url: project_graph_path(@project, ref) },
- { category: "In this project", label: "Issues", url: project_issues_path(@project) },
- { category: "In this project", label: "Merge Requests", url: project_merge_requests_path(@project) },
- { category: "In this project", label: "Milestones", url: project_milestones_path(@project) },
- { category: "In this project", label: "Snippets", url: project_snippets_path(@project) },
- { category: "In this project", label: "Members", url: project_project_members_path(@project) },
- { category: "In this project", label: "Wiki", url: project_wikis_path(@project) }
+ { category: "In this project", label: _("Files"), url: project_tree_path(@project, ref) },
+ { category: "In this project", label: _("Commits"), url: project_commits_path(@project, ref) },
+ { category: "In this project", label: _("Network"), url: project_network_path(@project, ref) },
+ { category: "In this project", label: _("Graph"), url: project_graph_path(@project, ref) },
+ { category: "In this project", label: _("Issues"), url: project_issues_path(@project) },
+ { category: "In this project", label: _("Merge Requests"), url: project_merge_requests_path(@project) },
+ { category: "In this project", label: _("Milestones"), url: project_milestones_path(@project) },
+ { category: "In this project", label: _("Snippets"), url: project_snippets_path(@project) },
+ { category: "In this project", label: _("Members"), url: project_project_members_path(@project) },
+ { category: "In this project", label: _("Wiki"), url: project_wikis_path(@project) }
]
else
[]
@@ -162,7 +162,7 @@ module SearchHelper
opts =
{
id: "filtered-search-#{type}",
- placeholder: 'Search or filter results...',
+ placeholder: _('Search or filter results...'),
data: {
'username-params' => UserSerializer.new.represent(@users)
},
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index c5bab877c00..4690b6ffbe1 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -86,17 +86,17 @@ module TreeHelper
end
def edit_in_new_fork_notice_now
- "You're not allowed to make changes to this project directly." +
- " A fork of this project is being created that you can make changes in, so you can submit a merge request."
+ _("You're not allowed to make changes to this project directly. "\
+ "A fork of this project is being created that you can make changes in, so you can submit a merge request.")
end
def edit_in_new_fork_notice
- "You're not allowed to make changes to this project directly." +
- " A fork of this project has been created that you can make changes in, so you can submit a merge request."
+ _("You're not allowed to make changes to this project directly. "\
+ "A fork of this project has been created that you can make changes in, so you can submit a merge request.")
end
def edit_in_new_fork_notice_action(action)
- edit_in_new_fork_notice + " Try to #{action} this file again."
+ edit_in_new_fork_notice + _(" Try to %{action} this file again.") % { action: action }
end
def commit_in_fork_help
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index 712f0f808dd..9deb783d289 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -42,11 +42,11 @@ module VisibilityLevelHelper
def group_visibility_level_description(level)
case level
when Gitlab::VisibilityLevel::PRIVATE
- "The group and its projects can only be viewed by members."
+ _("The group and its projects can only be viewed by members.")
when Gitlab::VisibilityLevel::INTERNAL
- "The group and any internal projects can be viewed by any logged in user."
+ _("The group and any internal projects can be viewed by any logged in user.")
when Gitlab::VisibilityLevel::PUBLIC
- "The group and any public projects can be viewed without any authentication."
+ _("The group and any public projects can be viewed without any authentication.")
end
end
@@ -54,20 +54,20 @@ module VisibilityLevelHelper
case level
when Gitlab::VisibilityLevel::PRIVATE
if snippet.is_a? ProjectSnippet
- "The snippet is visible only to project members."
+ _("The snippet is visible only to project members.")
else
- "The snippet is visible only to me."
+ _("The snippet is visible only to me.")
end
when Gitlab::VisibilityLevel::INTERNAL
- "The snippet is visible to any logged in user."
+ _("The snippet is visible to any logged in user.")
when Gitlab::VisibilityLevel::PUBLIC
- "The snippet can be accessed without any authentication."
+ _("The snippet can be accessed without any authentication.")
end
end
def restricted_visibility_level_description(level)
level_name = Gitlab::VisibilityLevel.level_name(level)
- "#{level_name.capitalize} visibility has been restricted by the administrator."
+ _("%{level_name} visibility has been restricted by the administrator.") % { level_name: level_name.capitalize }
end
def disallowed_visibility_level_description(level, form_model)
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
index 647f34e57ed..edd48f82729 100644
--- a/app/helpers/wiki_helper.rb
+++ b/app/helpers/wiki_helper.rb
@@ -47,4 +47,24 @@ module WikiHelper
def wiki_attachment_upload_url
expose_url(api_v4_projects_wikis_attachments_path(id: @project.id))
end
+
+ def wiki_sort_controls(project, sort, direction)
+ sort ||= ProjectWiki::TITLE_ORDER
+ link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort'
+ reversed_direction = direction == 'desc' ? 'asc' : 'desc'
+ icon_class = direction == 'desc' ? 'highest' : 'lowest'
+
+ link_to(project_wikis_pages_path(project, sort: sort, direction: reversed_direction),
+ type: 'button', class: link_class, title: _('Sort direction')) do
+ sprite_icon("sort-#{icon_class}", size: 16)
+ end
+ end
+
+ def wiki_sort_title(key)
+ if key == ProjectWiki::CREATED_AT_ORDER
+ s_("Wiki|Created date")
+ else
+ s_("Wiki|Title")
+ end
+ end
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 9e91e4ab4b9..d28a12413bf 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -213,6 +213,40 @@ class ApplicationSetting < ApplicationRecord
validate :terms_exist, if: :enforce_terms?
+ validates :external_authorization_service_default_label,
+ presence: true,
+ if: :external_authorization_service_enabled
+
+ validates :external_authorization_service_url,
+ url: true, allow_blank: true,
+ if: :external_authorization_service_enabled
+
+ validates :external_authorization_service_timeout,
+ numericality: { greater_than: 0, less_than_or_equal_to: 10 },
+ if: :external_authorization_service_enabled
+
+ validates :external_auth_client_key,
+ presence: true,
+ if: -> (setting) { setting.external_auth_client_cert.present? }
+
+ validates_with X509CertificateCredentialsValidator,
+ certificate: :external_auth_client_cert,
+ pkey: :external_auth_client_key,
+ pass: :external_auth_client_key_pass,
+ if: -> (setting) { setting.external_auth_client_cert.present? }
+
+ attr_encrypted :external_auth_client_key,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm',
+ encode: true
+
+ attr_encrypted :external_auth_client_key_pass,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm',
+ encode: true
+
before_validation :ensure_uuid!
before_validation :strip_sentry_values
@@ -223,4 +257,11 @@ class ApplicationSetting < ApplicationRecord
reset_memoized_terms
end
after_commit :expire_performance_bar_allowed_user_ids_cache, if: -> { previous_changes.key?('performance_bar_allowed_group_id') }
+
+ def self.create_from_defaults
+ super
+ rescue ActiveRecord::RecordNotUnique
+ # We already have an ApplicationSetting record, so just return it.
+ current_without_cache
+ end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 1bd517641ac..b8a76e662b0 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -83,8 +83,13 @@ module Ci
scope :unstarted, ->() { where(runner_id: nil) }
scope :ignore_failures, ->() { where(allow_failure: false) }
scope :with_artifacts_archive, ->() do
- where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)',
- '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive)
+ if Feature.enabled?(:ci_enable_legacy_artifacts)
+ where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)',
+ '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive)
+ else
+ where('EXISTS (?)',
+ Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive)
+ end
end
scope :with_existing_job_artifacts, ->(query) do
@@ -135,6 +140,8 @@ module Ci
where("EXISTS (?)", matcher)
end
+ ##
+ # TODO: Remove these mounters when we remove :ci_enable_legacy_artifacts feature flag
mount_uploader :legacy_artifacts_file, LegacyArtifactUploader, mount_on: :artifacts_file
mount_uploader :legacy_artifacts_metadata, LegacyArtifactUploader, mount_on: :artifacts_metadata
@@ -775,7 +782,7 @@ module Ci
private
def erase_old_artifacts!
- # TODO: To be removed once we get rid of
+ # TODO: To be removed once we get rid of ci_enable_legacy_artifacts feature flag
remove_artifacts_file!
remove_artifacts_metadata!
save
diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb
index 061eff090f5..80dbb150085 100644
--- a/app/models/ci/build_runner_session.rb
+++ b/app/models/ci/build_runner_session.rb
@@ -6,6 +6,8 @@ module Ci
class BuildRunnerSession < ApplicationRecord
extend Gitlab::Ci::Model
+ TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com'.freeze
+
self.table_name = 'ci_builds_runner_session'
belongs_to :build, class_name: 'Ci::Build', inverse_of: :runner_session
@@ -14,11 +16,21 @@ module Ci
validates :url, url: { protocols: %w(https) }
def terminal_specification
- return {} unless url.present?
+ wss_url = Gitlab::UrlHelpers.as_wss(self.url)
+ return {} unless wss_url.present?
+
+ wss_url = "#{wss_url}/exec"
+ channel_specification(wss_url, TERMINAL_SUBPROTOCOL)
+ end
+
+ private
+
+ def channel_specification(url, subprotocol)
+ return {} if subprotocol.blank? || url.blank?
{
- subprotocols: ['terminal.gitlab.com'].freeze,
- url: "#{url}/exec".sub("https://", "wss://"),
+ subprotocols: Array(subprotocol),
+ url: url,
headers: { Authorization: [authorization.presence] }.compact,
ca_pem: certificate.presence
}
diff --git a/app/models/concerns/artifact_migratable.rb b/app/models/concerns/artifact_migratable.rb
index cbd63ba8876..7c9f579b480 100644
--- a/app/models/concerns/artifact_migratable.rb
+++ b/app/models/concerns/artifact_migratable.rb
@@ -13,7 +13,7 @@ module ArtifactMigratable
end
def artifacts?
- !artifacts_expired? && artifacts_file.exists?
+ !artifacts_expired? && artifacts_file&.exists?
end
def artifacts_metadata?
@@ -43,4 +43,16 @@ module ArtifactMigratable
def artifacts_size
read_attribute(:artifacts_size).to_i + job_artifacts.sum(:size).to_i
end
+
+ def legacy_artifacts_file
+ return unless Feature.enabled?(:ci_enable_legacy_artifacts)
+
+ super
+ end
+
+ def legacy_artifacts_metadata
+ return unless Feature.enabled?(:ci_enable_legacy_artifacts)
+
+ super
+ end
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 51a8395c013..17f94b4bd9b 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -172,6 +172,10 @@ module Issuable
fuzzy_search(query, matched_columns)
end
+ def simple_sorts
+ super.except('name_asc', 'name_desc')
+ end
+
def sort_by_attribute(method, excluded_labels: [])
sorted =
case method.to_s
diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb
index a29e80fe0c1..decbbbd87f2 100644
--- a/app/models/concerns/prometheus_adapter.rb
+++ b/app/models/concerns/prometheus_adapter.rb
@@ -51,7 +51,7 @@ module PrometheusAdapter
end
def build_query_args(*args)
- args.map(&:id)
+ args.map { |arg| arg.respond_to?(:id) ? arg.id : arg }
end
end
end
diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb
index 1ab3b3ddc46..1e09cd89550 100644
--- a/app/models/concerns/reactive_caching.rb
+++ b/app/models/concerns/reactive_caching.rb
@@ -29,6 +29,40 @@
# However, it will enqueue a background worker to call `#calculate_reactive_cache`
# and set an initial cache lifetime of ten minutes.
#
+# The background worker needs to find or generate the object on which
+# `with_reactive_cache` was called.
+# The default behaviour can be overridden by defining a custom
+# `reactive_cache_worker_finder`.
+# Otherwise the background worker will use the class name and primary key to get
+# the object using the ActiveRecord find_by method.
+#
+# class Bar
+# include ReactiveCaching
+#
+# self.reactive_cache_key = ->() { ["bar", "thing"] }
+# self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
+#
+# def self.from_cache(var1, var2)
+# # This method will be called by the background worker with "bar1" and
+# # "bar2" as arguments.
+# new(var1, var2)
+# end
+#
+# def initialize(var1, var2)
+# # ...
+# end
+#
+# def calculate_reactive_cache
+# # Expensive operation here. The return value of this method is cached
+# end
+#
+# def result
+# with_reactive_cache("bar1", "bar2") do |data|
+# # ...
+# end
+# end
+# end
+#
# Each time the background job completes, it stores the return value of
# `#calculate_reactive_cache`. It is also re-enqueued to run again after
# `reactive_cache_refresh_interval`, so keeping the stored value up to date.
@@ -52,6 +86,7 @@ module ReactiveCaching
class_attribute :reactive_cache_key
class_attribute :reactive_cache_lifetime
class_attribute :reactive_cache_refresh_interval
+ class_attribute :reactive_cache_worker_finder
# defaults
self.reactive_cache_lease_timeout = 2.minutes
@@ -59,6 +94,10 @@ module ReactiveCaching
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 10.minutes
+ self.reactive_cache_worker_finder = ->(id, *_args) do
+ find_by(primary_key => id)
+ end
+
def calculate_reactive_cache(*args)
raise NotImplementedError
end
diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb
index 29e48f0c5f7..df1a9e3fe6e 100644
--- a/app/models/concerns/sortable.rb
+++ b/app/models/concerns/sortable.rb
@@ -21,19 +21,21 @@ module Sortable
class_methods do
def order_by(method)
- case method.to_s
- when 'created_asc' then order_created_asc
- when 'created_date' then order_created_desc
- when 'created_desc' then order_created_desc
- when 'id_asc' then order_id_asc
- when 'id_desc' then order_id_desc
- when 'name_asc' then order_name_asc
- when 'name_desc' then order_name_desc
- when 'updated_asc' then order_updated_asc
- when 'updated_desc' then order_updated_desc
- else
- all
- end
+ simple_sorts.fetch(method.to_s, -> { all }).call
+ end
+
+ def simple_sorts
+ {
+ 'created_asc' => -> { order_created_asc },
+ 'created_date' => -> { order_created_desc },
+ 'created_desc' => -> { order_created_desc },
+ 'id_asc' => -> { order_id_asc },
+ 'id_desc' => -> { order_id_desc },
+ 'name_asc' => -> { order_name_asc },
+ 'name_desc' => -> { order_name_desc },
+ 'updated_asc' => -> { order_updated_asc },
+ 'updated_desc' => -> { order_updated_desc }
+ }
end
private
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 25373c7a1f7..fa29a83e517 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -170,8 +170,10 @@ class Environment < ApplicationRecord
prometheus_adapter.query(:environment, self) if has_metrics?
end
- def additional_metrics
- prometheus_adapter.query(:additional_metrics_environment, self) if has_metrics?
+ def additional_metrics(*args)
+ return unless has_metrics?
+
+ prometheus_adapter.query(:additional_metrics_environment, self, *args.map(&:to_f))
end
# rubocop: disable CodeReuse/ServiceClass
diff --git a/app/models/global_label.rb b/app/models/global_label.rb
index c5b2492bbf6..572cb12b26a 100644
--- a/app/models/global_label.rb
+++ b/app/models/global_label.rb
@@ -4,7 +4,7 @@ class GlobalLabel
attr_accessor :title, :labels
alias_attribute :name, :title
- delegate :color, :text_color, :description, to: :@first_label
+ delegate :color, :text_color, :description, :scoped_label?, to: :@first_label
def for_display
@first_label
diff --git a/app/models/group.rb b/app/models/group.rb
index c77586c4cdc..ac66815705c 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -228,22 +228,21 @@ class Group < Namespace
def has_owner?(user)
return false unless user
- members_with_parents.owners.where(user_id: user).any?
+ members_with_parents.owners.exists?(user_id: user)
end
def has_maintainer?(user)
return false unless user
- members_with_parents.maintainers.where(user_id: user).any?
+ members_with_parents.maintainers.exists?(user_id: user)
end
# @deprecated
alias_method :has_master?, :has_maintainer?
# Check if user is a last owner of the group.
- # Parent owners are ignored for nested groups.
def last_owner?(user)
- owners.include?(user) && owners.size == 1
+ has_owner?(user) && members_with_parents.owners.size == 1
end
def ldap_synced?
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 97c6dcc4745..eb4c87e05d5 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -230,7 +230,13 @@ class Issue < ApplicationRecord
def visible_to_user?(user = nil)
return false unless project && project.feature_available?(:issues, user)
- user ? readable_by?(user) : publicly_visible?
+ return publicly_visible? unless user
+
+ return false unless readable_by?(user)
+
+ user.full_private_access? ||
+ ::Gitlab::ExternalAuthorization.access_allowed?(
+ user, project.external_authorization_classification_label)
end
def check_for_spam?
@@ -298,7 +304,7 @@ class Issue < ApplicationRecord
# Returns `true` if this Issue is visible to everybody.
def publicly_visible?
- project.public? && !confidential?
+ project.public? && !confidential? && !::Gitlab::ExternalAuthorization.enabled?
end
def expire_etag_cache
diff --git a/app/models/project.rb b/app/models/project.rb
index e2869fc2ad5..97e287d3fa2 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -2062,6 +2062,11 @@ class Project < ApplicationRecord
fetch_branch_allows_collaboration(user, branch_name)
end
+ def external_authorization_classification_label
+ super || ::Gitlab::CurrentSettings.current_application_settings
+ .external_authorization_service_default_label
+ end
+
def licensed_features
[]
end
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index 268706a6aea..23ddd708396 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -13,6 +13,11 @@ class ProjectWiki
CouldNotCreateWikiError = Class.new(StandardError)
SIDEBAR = '_sidebar'
+ TITLE_ORDER = 'title'
+ CREATED_AT_ORDER = 'created_at'
+ DIRECTION_DESC = 'desc'
+ DIRECTION_ASC = 'asc'
+
# Returns a string describing what went wrong after
# an operation fails.
attr_reader :error_message
@@ -82,8 +87,15 @@ class ProjectWiki
# Returns an Array of GitLab WikiPage instances or an
# empty Array if this Wiki has no pages.
- def pages(limit: 0)
- wiki.pages(limit: limit).map { |page| WikiPage.new(self, page, true) }
+ def pages(limit: 0, sort: nil, direction: DIRECTION_ASC)
+ sort ||= TITLE_ORDER
+ direction_desc = direction == DIRECTION_DESC
+
+ wiki.pages(
+ limit: limit, sort: sort, direction_desc: direction_desc
+ ).map do |page|
+ WikiPage.new(self, page, true)
+ end
end
# Finds a page within the repository based on a tile
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 574ce12b309..51ab2247a03 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -299,13 +299,14 @@ class Repository
end
end
- def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:)
+ def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:, path: nil)
raw_repository.archive_metadata(
ref,
storage_path,
project.path,
format,
- append_sha: append_sha
+ append_sha: append_sha,
+ path: path
)
end
diff --git a/app/models/user.rb b/app/models/user.rb
index b426d100537..259889995d3 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -159,7 +159,7 @@ class User < ApplicationRecord
# Validations
#
# Note: devise :validatable above adds validations for :email and :password
- validates :name, presence: true
+ validates :name, presence: true, length: { maximum: 128 }
validates :email, confirmation: true
validates :notification_email, presence: true
validates :notification_email, devise_email: true, if: ->(user) { user.notification_email != user.email }
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 64daba81dcf..909da4316d0 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -28,16 +28,15 @@ class WikiPage
def self.group_by_directory(pages)
return [] if pages.blank?
- pages.sort_by { |page| [page.directory, page.slug] }
- .group_by(&:directory)
- .map do |dir, pages|
- if dir.present?
- WikiDirectory.new(dir, pages)
- else
- pages
- end
- end
- .flatten
+ pages.each_with_object([]) do |page, grouped_pages|
+ next grouped_pages << page unless page.directory.present?
+
+ directory = grouped_pages.find { |dir| dir.slug == page.directory }
+
+ next directory.pages << page if directory
+
+ grouped_pages << WikiDirectory.new(page.directory, [page])
+ end
end
def self.unhyphenize(name)
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 72de04203a6..5dd2279ef99 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -22,6 +22,13 @@ class BasePolicy < DeclarativePolicy::Base
Gitlab::CurrentSettings.current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC)
end
- # This is prevented in some cases in `gitlab-ee`
+ condition(:external_authorization_enabled, scope: :global, score: 0) do
+ ::Gitlab::ExternalAuthorization.perform_check?
+ end
+
+ rule { external_authorization_enabled & ~full_private_access }.policy do
+ prevent :read_cross_project
+ end
+
rule { default }.enable :read_cross_project
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 75825c8fac0..ba38af9c529 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -89,6 +89,15 @@ class ProjectPolicy < BasePolicy
::Gitlab::CurrentSettings.current_application_settings.mirror_available
end
+ with_scope :subject
+ condition(:classification_label_authorized, score: 32) do
+ ::Gitlab::ExternalAuthorization.access_allowed?(
+ @user,
+ @subject.external_authorization_classification_label,
+ @subject.full_path
+ )
+ end
+
# We aren't checking `:read_issue` or `:read_merge_request` in this case
# because it could be possible for a user to see an issuable-iid
# (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be
@@ -204,6 +213,7 @@ class ProjectPolicy < BasePolicy
enable :read_merge_request
enable :read_sentry_issue
enable :read_release
+ enable :read_prometheus
end
# We define `:public_user_access` separately because there are cases in gitlab-ee
@@ -416,6 +426,25 @@ class ProjectPolicy < BasePolicy
rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster
+ rule { ~can?(:read_cross_project) & ~classification_label_authorized }.policy do
+ # Preventing access here still allows the projects to be listed. Listing
+ # projects doesn't check the `:read_project` ability. But instead counts
+ # on the `project_authorizations` table.
+ #
+ # All other actions should explicitly check read project, which would
+ # trigger the `classification_label_authorized` condition.
+ #
+ # `:read_project_for_iids` is not prevented by this condition, as it is
+ # used for cross-project reference checks.
+ prevent :guest_access
+ prevent :public_access
+ prevent :public_user_access
+ prevent :reporter_access
+ prevent :developer_access
+ prevent :maintainer_access
+ prevent :owner_access
+ end
+
private
def team_member?
diff --git a/app/presenters/ci/bridge_presenter.rb b/app/presenters/ci/bridge_presenter.rb
new file mode 100644
index 00000000000..ee11cffe355
--- /dev/null
+++ b/app/presenters/ci/bridge_presenter.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Ci
+ class BridgePresenter < CommitStatusPresenter
+ def detailed_status
+ @detailed_status ||= subject.detailed_status(user)
+ end
+ end
+end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index 9ddce0d2c80..62c26809eeb 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -45,6 +45,8 @@ class BuildDetailsEntity < JobEntity
erase_project_job_path(project, build)
end
+ expose :failure_reason, if: -> (*) { build.failed? }
+
expose :terminal_path, if: -> (*) { can_create_build_terminal? } do |build|
terminal_project_job_path(project, build)
end
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index c3f7d4651fb..914ad628a99 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -42,6 +42,6 @@ class IssueEntity < IssuableEntity
end
expose :preview_note_path do |issue|
- preview_markdown_path(issue.project, quick_actions_target_type: 'Issue', quick_actions_target_id: issue.iid)
+ preview_markdown_path(issue.project, target_type: 'Issue', target_id: issue.iid)
end
end
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index d673f8ae896..4831eb32c96 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -235,7 +235,7 @@ class MergeRequestWidgetEntity < IssuableEntity
end
expose :preview_note_path do |merge_request|
- preview_markdown_path(merge_request.project, quick_actions_target_type: 'MergeRequest', quick_actions_target_id: merge_request.iid)
+ preview_markdown_path(merge_request.project, target_type: 'MergeRequest', target_id: merge_request.iid)
end
expose :merge_commit_path do |merge_request|
diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb
index 4d0d4da10be..2dd62e19e29 100644
--- a/app/serializers/suggestion_entity.rb
+++ b/app/serializers/suggestion_entity.rb
@@ -3,6 +3,8 @@
class SuggestionEntity < API::Entities::Suggestion
include RequestAwareEntity
+ unexpose :from_line, :to_line, :from_content, :to_content
+ expose :diff_lines, using: DiffLineEntity
expose :current_user do
expose :can_apply do |suggestion|
Ability.allowed?(current_user, :apply_suggestion, suggestion)
diff --git a/app/serializers/suggestion_serializer.rb b/app/serializers/suggestion_serializer.rb
new file mode 100644
index 00000000000..010344f9fcd
--- /dev/null
+++ b/app/serializers/suggestion_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class SuggestionSerializer < BaseSerializer
+ entity SuggestionEntity
+
+ def represent_diff(resource)
+ represent(resource, { only: [:diff_lines] })
+ end
+end
diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb
index 9146eb96533..7eeaf8aade1 100644
--- a/app/services/application_settings/update_service.rb
+++ b/app/services/application_settings/update_service.rb
@@ -2,9 +2,17 @@
module ApplicationSettings
class UpdateService < ApplicationSettings::BaseService
+ include ValidatesClassificationLabel
+
attr_reader :params, :application_setting
def execute
+ validate_classification_label(application_setting, :external_authorization_service_default_label)
+
+ if application_setting.errors.any?
+ return false
+ end
+
update_terms(@params.delete(:terms))
if params.key?(:performance_bar_allowed_group_path)
diff --git a/app/services/clusters/applications/base_helm_service.rb b/app/services/clusters/applications/base_helm_service.rb
index c38b2656260..adaa68b1efb 100644
--- a/app/services/clusters/applications/base_helm_service.rb
+++ b/app/services/clusters/applications/base_helm_service.rb
@@ -13,16 +13,20 @@ module Clusters
def log_error(error)
meta = {
- exception: error.class.name,
error_code: error.respond_to?(:error_code) ? error.error_code : nil,
service: self.class.name,
app_id: app.id,
project_ids: app.cluster.project_ids,
- group_ids: app.cluster.group_ids,
- message: error.message
+ group_ids: app.cluster.group_ids
}
- logger.error(meta)
+ logger_meta = meta.merge(
+ exception: error.class.name,
+ message: error.message,
+ backtrace: Gitlab::Profiler.clean_backtrace(error.backtrace)
+ )
+
+ logger.error(logger_meta)
Gitlab::Sentry.track_acceptable_exception(error, extra: meta)
end
diff --git a/app/services/concerns/suggestible.rb b/app/services/concerns/suggestible.rb
index 0b9822b1909..0cba9bf1b8a 100644
--- a/app/services/concerns/suggestible.rb
+++ b/app/services/concerns/suggestible.rb
@@ -2,10 +2,17 @@
module Suggestible
extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
# This translates into limiting suggestion changes to `suggestion:-100+100`.
MAX_LINES_CONTEXT = 100.freeze
+ def diff_lines
+ strong_memoize(:diff_lines) do
+ Gitlab::Diff::SuggestionDiff.new(self).diff_lines
+ end
+ end
+
def fetch_from_content
diff_file.new_blob_lines_between(from_line, to_line).join
end
diff --git a/app/services/concerns/validates_classification_label.rb b/app/services/concerns/validates_classification_label.rb
new file mode 100644
index 00000000000..ebcf5c24ff8
--- /dev/null
+++ b/app/services/concerns/validates_classification_label.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module ValidatesClassificationLabel
+ def validate_classification_label(record, attribute_name)
+ return unless ::Gitlab::ExternalAuthorization.enabled?
+ return unless classification_label_change?(record, attribute_name)
+
+ new_label = params[attribute_name].presence
+ new_label ||= ::Gitlab::CurrentSettings.current_application_settings
+ .external_authorization_service_default_label
+
+ unless ::Gitlab::ExternalAuthorization.access_allowed?(current_user, new_label)
+ reason = rejection_reason_for_label(new_label)
+ message = s_('ClassificationLabelUnavailable|is unavailable: %{reason}') % { reason: reason }
+ record.errors.add(attribute_name, message)
+ end
+ end
+
+ def rejection_reason_for_label(label)
+ reason_from_service = ::Gitlab::ExternalAuthorization.rejection_reason(current_user, label).presence
+ reason_from_service || _("Access to '%{classification_label}' not allowed") % { classification_label: label }
+ end
+
+ def classification_label_change?(record, attribute_name)
+ params.key?(attribute_name) || record.new_record?
+ end
+end
diff --git a/app/services/groups/base_service.rb b/app/services/groups/base_service.rb
index 8c8acce5ca5..019cd047ae9 100644
--- a/app/services/groups/base_service.rb
+++ b/app/services/groups/base_service.rb
@@ -7,5 +7,11 @@ module Groups
def initialize(group, user, params = {})
@group, @current_user, @params = group, user, params.dup
end
+
+ private
+
+ def remove_unallowed_params
+ # overridden in EE
+ end
end
end
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 99ead467f74..74aad3b1c94 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -8,6 +8,8 @@ module Groups
end
def execute
+ remove_unallowed_params
+
@group = Group.new(params)
after_build_hook(@group, params)
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index 787445180f0..73e1e00dc33 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -6,6 +6,7 @@ module Groups
def execute
reject_parent_id!
+ remove_unallowed_params
return false unless valid_visibility_level_change?(group, params[:visibility_level])
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 04dfcfbc22d..7a4ccf0d178 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -107,12 +107,13 @@ class IssuableBaseService < BaseService
@labels_service ||= ::Labels::AvailableLabelsService.new(current_user, parent, params)
end
- def process_label_ids(attributes, existing_label_ids: nil)
+ def process_label_ids(attributes, existing_label_ids: nil, extra_label_ids: [])
label_ids = attributes.delete(:label_ids)
add_label_ids = attributes.delete(:add_label_ids)
remove_label_ids = attributes.delete(:remove_label_ids)
new_label_ids = existing_label_ids || label_ids || []
+ new_label_ids |= extra_label_ids
if add_label_ids.blank? && remove_label_ids.blank?
new_label_ids = label_ids if label_ids
@@ -147,7 +148,7 @@ class IssuableBaseService < BaseService
params.delete(:state_event)
params[:author] ||= current_user
- params[:label_ids] = issuable.label_ids.to_a + process_label_ids(params)
+ params[:label_ids] = process_label_ids(params, extra_label_ids: issuable.label_ids.to_a)
issuable.assign_attributes(params)
diff --git a/app/services/merge_requests/add_todo_when_build_fails_service.rb b/app/services/merge_requests/add_todo_when_build_fails_service.rb
index 79c43b8e7d5..d3ef892875b 100644
--- a/app/services/merge_requests/add_todo_when_build_fails_service.rb
+++ b/app/services/merge_requests/add_todo_when_build_fails_service.rb
@@ -7,7 +7,7 @@ module MergeRequests
def execute(commit_status)
return if commit_status.allow_failure? || commit_status.retried?
- commit_status_merge_requests(commit_status) do |merge_request|
+ pipeline_merge_requests(commit_status.pipeline) do |merge_request|
todo_service.merge_request_build_failed(merge_request)
end
end
@@ -16,7 +16,7 @@ module MergeRequests
# build is retried
#
def close(commit_status)
- commit_status_merge_requests(commit_status) do |merge_request|
+ pipeline_merge_requests(commit_status.pipeline) do |merge_request|
todo_service.merge_request_build_retried(merge_request)
end
end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 3e208241da5..8a9e5ebb014 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -55,15 +55,7 @@ module MergeRequests
end
def create_pipeline_for(merge_request, user)
- return unless Feature.enabled?(:ci_merge_request_pipeline,
- merge_request.source_project,
- default_enabled: true)
-
- ##
- # UpdateMergeRequestsWorker could be retried by an exception.
- # MR pipelines should not be recreated in such case.
- return if merge_request.merge_request_pipeline_exists?
- return if merge_request.has_no_commits?
+ return unless can_create_pipeline_for?(merge_request)
create_detached_merge_request_pipeline(merge_request, user)
end
@@ -80,6 +72,16 @@ module MergeRequests
end
end
+ def can_create_pipeline_for?(merge_request)
+ ##
+ # UpdateMergeRequestsWorker could be retried by an exception.
+ # pipelines for merge request should not be recreated in such case.
+ return false if merge_request.merge_request_pipeline_exists?
+ return false if merge_request.has_no_commits?
+
+ true
+ end
+
def can_use_merge_request_ref?(merge_request)
Feature.enabled?(:ci_use_merge_request_ref, project, default_enabled: true) &&
!merge_request.for_fork?
@@ -97,22 +99,11 @@ module MergeRequests
# rubocop: enable CodeReuse/ActiveRecord
def pipeline_merge_requests(pipeline)
- merge_requests_for(pipeline.ref).each do |merge_request|
+ pipeline.all_merge_requests.opened.each do |merge_request|
next unless pipeline == merge_request.head_pipeline
yield merge_request
end
end
-
- def commit_status_merge_requests(commit_status)
- merge_requests_for(commit_status.ref).each do |merge_request|
- pipeline = merge_request.head_pipeline
-
- next unless pipeline
- next unless pipeline.sha == commit_status.sha
-
- yield merge_request
- end
- end
end
end
diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb
index c1655c38095..7386530f45f 100644
--- a/app/services/preview_markdown_service.rb
+++ b/app/services/preview_markdown_service.rb
@@ -17,7 +17,7 @@ class PreviewMarkdownService < BaseService
private
def explain_quick_actions(text)
- return text, [] unless %w(Issue MergeRequest Commit).include?(commands_target_type)
+ return text, [] unless %w(Issue MergeRequest Commit).include?(target_type)
quick_actions_service = QuickActions::InterpretService.new(project, current_user)
quick_actions_service.explain(text, find_commands_target)
@@ -30,22 +30,34 @@ class PreviewMarkdownService < BaseService
end
def find_suggestions(text)
- return [] unless params[:preview_suggestions]
+ return [] unless preview_sugestions?
- Banzai::SuggestionsParser.parse(text)
+ position = Gitlab::Diff::Position.new(new_path: params[:file_path],
+ new_line: params[:line].to_i,
+ base_sha: params[:base_sha],
+ head_sha: params[:head_sha],
+ start_sha: params[:start_sha])
+
+ Gitlab::Diff::SuggestionsParser.parse(text, position: position, project: project)
+ end
+
+ def preview_sugestions?
+ params[:preview_suggestions] &&
+ target_type == 'MergeRequest' &&
+ Ability.allowed?(current_user, :download_code, project)
end
def find_commands_target
QuickActions::TargetService
.new(project, current_user)
- .execute(commands_target_type, commands_target_id)
+ .execute(target_type, target_id)
end
- def commands_target_type
- params[:quick_actions_target_type]
+ def target_type
+ params[:target_type]
end
- def commands_target_id
- params[:quick_actions_target_id]
+ def target_id
+ params[:target_id]
end
end
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index d03137b63b2..3723c5ef7d7 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -2,6 +2,8 @@
module Projects
class CreateService < BaseService
+ include ValidatesClassificationLabel
+
def initialize(user, params)
@current_user, @params = user, params.dup
@skip_wiki = @params.delete(:skip_wiki)
@@ -45,6 +47,8 @@ module Projects
relations_block&.call(@project)
yield(@project) if block_given?
+ validate_classification_label(@project, :external_authorization_classification_label)
+
# If the block added errors, don't try to save the project
return @project if @project.errors.any?
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index 6856009b395..bc36bb8659d 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -3,6 +3,7 @@
module Projects
class UpdateService < BaseService
include UpdateVisibilityLevel
+ include ValidatesClassificationLabel
ValidationError = Class.new(StandardError)
@@ -14,6 +15,8 @@ module Projects
yield if block_given?
+ validate_classification_label(project, :external_authorization_classification_label)
+
# If the block added errors, don't try to save the project
return update_failed! if project.errors.any?
diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb
new file mode 100644
index 00000000000..c5d2b84878b
--- /dev/null
+++ b/app/services/prometheus/proxy_service.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+module Prometheus
+ class ProxyService < BaseService
+ include ReactiveCaching
+ include Gitlab::Utils::StrongMemoize
+
+ self.reactive_cache_key = ->(service) { service.cache_key }
+ self.reactive_cache_lease_timeout = 30.seconds
+ self.reactive_cache_refresh_interval = 30.seconds
+ self.reactive_cache_lifetime = 1.minute
+ self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
+
+ attr_accessor :proxyable, :method, :path, :params
+
+ PROXY_SUPPORT = {
+ 'query' => {
+ method: ['GET'],
+ params: %w(query time timeout)
+ },
+ 'query_range' => {
+ method: ['GET'],
+ params: %w(query start end step timeout)
+ }
+ }.freeze
+
+ def self.from_cache(proxyable_class_name, proxyable_id, method, path, params)
+ proxyable_class = begin
+ proxyable_class_name.constantize
+ rescue NameError
+ nil
+ end
+ return unless proxyable_class
+
+ proxyable = proxyable_class.find(proxyable_id)
+
+ new(proxyable, method, path, params)
+ end
+
+ # proxyable can be any model which responds to .prometheus_adapter
+ # like Environment.
+ def initialize(proxyable, method, path, params)
+ @proxyable = proxyable
+ @path = path
+
+ # Convert ActionController::Parameters to hash because reactive_cache_worker
+ # does not play nice with ActionController::Parameters.
+ @params = filter_params(params, path).to_hash
+
+ @method = method
+ end
+
+ def id
+ nil
+ end
+
+ def execute
+ return cannot_proxy_response unless can_proxy?
+ return no_prometheus_response unless can_query?
+
+ with_reactive_cache(*cache_key) do |result|
+ result
+ end
+ end
+
+ def calculate_reactive_cache(proxyable_class_name, proxyable_id, method, path, params)
+ return no_prometheus_response unless can_query?
+
+ response = prometheus_client_wrapper.proxy(path, params)
+
+ success(http_status: response.code, body: response.body)
+ rescue Gitlab::PrometheusClient::Error => err
+ service_unavailable_response(err)
+ end
+
+ def cache_key
+ [@proxyable.class.name, @proxyable.id, @method, @path, @params]
+ end
+
+ private
+
+ def service_unavailable_response(exception)
+ error(exception.message, :service_unavailable)
+ end
+
+ def no_prometheus_response
+ error('No prometheus server found', :service_unavailable)
+ end
+
+ def cannot_proxy_response
+ error('Proxy support for this API is not available currently')
+ end
+
+ def prometheus_adapter
+ strong_memoize(:prometheus_adapter) do
+ @proxyable.prometheus_adapter
+ end
+ end
+
+ def prometheus_client_wrapper
+ prometheus_adapter&.prometheus_client_wrapper
+ end
+
+ def can_query?
+ prometheus_adapter&.can_query?
+ end
+
+ def filter_params(params, path)
+ params.slice(*PROXY_SUPPORT.dig(path, :params))
+ end
+
+ def can_proxy?
+ PROXY_SUPPORT.dig(@path, :method)&.include?(@method)
+ end
+ end
+end
diff --git a/app/uploaders/legacy_artifact_uploader.rb b/app/uploaders/legacy_artifact_uploader.rb
index a9afc104ed1..fac3c3dcb8f 100644
--- a/app/uploaders/legacy_artifact_uploader.rb
+++ b/app/uploaders/legacy_artifact_uploader.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+##
+# TODO: Remove this uploader when we remove :ci_enable_legacy_artifacts feature flag
+# See https://gitlab.com/gitlab-org/gitlab-ce/issues/58595
class LegacyArtifactUploader < GitlabUploader
extend Workhorse::UploadPath
include ObjectStorage::Concern
diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb
index 9a243e07936..00b51f92b12 100644
--- a/app/uploaders/records_uploads.rb
+++ b/app/uploaders/records_uploads.rb
@@ -46,6 +46,10 @@ module RecordsUploads
File.join(store_dir, filename.to_s)
end
+ def filename
+ upload&.path ? File.basename(upload.path) : super
+ end
+
private
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/validators/x509_certificate_credentials_validator.rb b/app/validators/x509_certificate_credentials_validator.rb
new file mode 100644
index 00000000000..d2f18e956c3
--- /dev/null
+++ b/app/validators/x509_certificate_credentials_validator.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+# X509CertificateCredentialsValidator
+#
+# Custom validator to check if certificate-attribute was signed using the
+# private key stored in an attrebute.
+#
+# This can be used as an `ActiveModel::Validator` as follows:
+#
+# validates_with X509CertificateCredentialsValidator,
+# certificate: :client_certificate,
+# pkey: :decrypted_private_key,
+# pass: :decrypted_passphrase
+#
+#
+# Required attributes:
+# - certificate: The name of the accessor that returns the certificate to check
+# - pkey: The name of the accessor that returns the private key
+# Optional:
+# - pass: The name of the accessor that returns the passphrase to decrypt the
+# private key
+class X509CertificateCredentialsValidator < ActiveModel::Validator
+ def initialize(*args)
+ super
+
+ # We can't validate if we don't have a private key or certificate attributes
+ # in which case this validator is useless.
+ if options[:pkey].nil? || options[:certificate].nil?
+ raise 'Provide at least `certificate` and `pkey` attribute names'
+ end
+ end
+
+ def validate(record)
+ unless certificate = read_certificate(record)
+ record.errors.add(options[:certificate], _('is not a valid X509 certificate.'))
+ end
+
+ unless private_key = read_private_key(record)
+ record.errors.add(options[:pkey], _('could not read private key, is the passphrase correct?'))
+ end
+
+ return if private_key.nil? || certificate.nil?
+
+ unless certificate.public_key.fingerprint == private_key.public_key.fingerprint
+ record.errors.add(options[:pkey], _('private key does not match certificate.'))
+ end
+ end
+
+ private
+
+ def read_private_key(record)
+ OpenSSL::PKey.read(pkey(record).to_s, pass(record).to_s)
+ rescue OpenSSL::PKey::PKeyError, ArgumentError
+ # When the primary key could not be read, an ArgumentError is raised.
+ # This hapens when the passed key is not valid or the passphrase is incorrect
+ nil
+ end
+
+ def read_certificate(record)
+ OpenSSL::X509::Certificate.new(certificate(record).to_s)
+ rescue OpenSSL::X509::CertificateError
+ nil
+ end
+
+ # rubocop:disable GitlabSecurity/PublicSend
+ #
+ # Allowing `#public_send` here because we don't want the validator to really
+ # care about the names of the attributes or where they come from.
+ #
+ # The credentials are mostly stored encrypted so we need to go through the
+ # accessors to get the values, `read_attribute` bypasses those.
+ def certificate(record)
+ record.public_send(options[:certificate])
+ end
+
+ def pkey(record)
+ record.public_send(options[:pkey])
+ end
+
+ def pass(record)
+ return unless options[:pass]
+
+ record.public_send(options[:pass])
+ end
+ # rubocop:enable GitlabSecurity/PublicSend
+end
diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
new file mode 100644
index 00000000000..01f6c7afe61
--- /dev/null
+++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
@@ -0,0 +1,51 @@
+%section.settings.as-external-auth.no-animate#js-external-auth-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('External authentication')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('External Classification Policy Authorization')
+ .settings-content
+
+ = form_for @application_setting, url: admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .form-check
+ = f.check_box :external_authorization_service_enabled, class: 'form-check-input'
+ = f.label :external_authorization_service_enabled, class: 'form-check-label' do
+ = _('Enable classification control using an external service')
+ %span.form-text.text-muted
+ = external_authorization_description
+ = link_to icon('question-circle'), help_page_path('user/admin_area/settings/external_authorization')
+ .form-group
+ = f.label :external_authorization_service_url, _('Service URL'), class: 'label-bold'
+ = f.text_field :external_authorization_service_url, class: 'form-control'
+ %span.form-text.text-muted
+ = external_authorization_url_help_text
+ .form-group
+ = f.label :external_authorization_service_timeout, _('External authorization request timeout'), class: 'label-bold'
+ = f.number_field :external_authorization_service_timeout, class: 'form-control', min: 0.001, max: 10, step: 0.001
+ %span.form-text.text-muted
+ = external_authorization_timeout_help_text
+ = f.label :external_auth_client_cert, _('Client authentication certificate'), class: 'label-bold'
+ = f.text_area :external_auth_client_cert, class: 'form-control'
+ %span.form-text.text-muted
+ = external_authorization_client_certificate_help_text
+ .form-group
+ = f.label :external_auth_client_key, _('Client authentication key'), class: 'label-bold'
+ = f.text_area :external_auth_client_key, class: 'form-control'
+ %span.form-text.text-muted
+ = external_authorization_client_key_help_text
+ .form-group
+ = f.label :external_auth_client_key_pass, _('Client authentication key password'), class: 'label-bold'
+ = f.password_field :external_auth_client_key_pass, class: 'form-control'
+ %span.form-text.text-muted
+ = external_authorization_client_pass_help_text
+ .form-group
+ = f.label :external_authorization_service_default_label, _('Default classification label'), class: 'label-bold'
+ = f.text_field :external_authorization_service_default_label, class: 'form-control'
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml
index fc9dd29b8ca..31f18ba0d56 100644
--- a/app/views/admin/application_settings/show.html.haml
+++ b/app/views/admin/application_settings/show.html.haml
@@ -68,7 +68,7 @@
.settings-content
= render 'terms'
-= render_if_exists 'admin/application_settings/external_authorization_service_form', expanded: expanded_by_default?
+= render 'admin/application_settings/external_authorization_service_form', expanded: expanded_by_default?
%section.settings.as-terminal.no-animate#js-terminal-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index 5e05568e384..8fb38f6a690 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -8,7 +8,7 @@
.form-group.row.group-description-holder
= f.label :avatar, _("Group avatar"), class: 'col-form-label col-sm-2'
.col-sm-10
- = render 'shared/choose_group_avatar_button', f: f
+ = render 'shared/choose_avatar_button', f: f
= render 'shared/old_visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false
diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml
index 00582e19662..3e0f8955081 100644
--- a/app/views/clusters/clusters/gcp/_form.html.haml
+++ b/app/views/clusters/clusters/gcp/_form.html.haml
@@ -7,25 +7,27 @@
- help_link_end = ' %{external_link_icon}</a>'.html_safe % { external_link_icon: external_link_icon }
%p
- - link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
- = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page}
+ - link_to_help_page = link_to(s_('ClusterIntegration|help page'),
+ help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page }
%p= link_to('Select a different Google account', @authorize_url)
-= form_for @gcp_cluster, html: { class: 'js-gke-cluster-creation prepend-top-20', data: { token: token_in_session } }, url: clusterable.create_gcp_clusters_path, as: :cluster do |field|
- = form_errors(@gcp_cluster)
- .form-group
- = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold'
- = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
+= bootstrap_form_for @gcp_cluster, html: { class: 'gl-show-field-errors js-gke-cluster-creation prepend-top-20',
+ data: { token: token_in_session } }, url: clusterable.create_gcp_clusters_path, as: :cluster do |field|
+ = field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'),
+ label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold'
- if has_multiple_clusters?
- .form-group
- = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-bold'
- = field.text_field :environment_scope, class: 'form-control', placeholder: s_('ClusterIntegration|Environment scope')
+ = field.form_group :environment_scope, label: { text: s_('ClusterIntegration|Environment scope'),
+ class: 'label-bold' } do
+ = field.text_field :environment_scope, required: true, class: 'form-control',
+ title: 'Environment scope is required.', wrapper: false
.form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.")
= field.fields_for :provider_gcp, @gcp_cluster.provider_gcp do |provider_gcp_field|
.form-group
- = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project'), class: 'label-bold'
+ = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project'),
+ class: 'label-bold'
.js-gcp-project-id-dropdown-entry-point{ data: { docsUrl: 'https://console.cloud.google.com/home/dashboard' } }
= provider_gcp_field.hidden_field :gcp_project_id
.dropdown
@@ -47,9 +49,9 @@
%p.form-text.text-muted
= s_('ClusterIntegration|Learn more about %{help_link_start}zones%{help_link_end}.').html_safe % { help_link_start: help_link_start % { url: zones_link_url }, help_link_end: help_link_end }
- .form-group
- = provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes'), class: 'label-bold'
- = provider_gcp_field.text_field :num_nodes, class: 'form-control', placeholder: '3'
+ = provider_gcp_field.number_field :num_nodes, required: true, placeholder: '3',
+ title: s_('ClusterIntegration|Number of nodes must be a numerical value.'),
+ label: s_('ClusterIntegration|Number of nodes'), label_class: 'label-bold'
.form-group
= provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type'), class: 'label-bold'
@@ -64,13 +66,14 @@
= s_('ClusterIntegration|Learn more about %{help_link_start_machine_type}machine types%{help_link_end} and %{help_link_start_pricing}pricing%{help_link_end}.').html_safe % { help_link_start_machine_type: help_link_start % { url: machine_type_link_url }, help_link_start_pricing: help_link_start % { url: pricing_link_url }, help_link_end: help_link_end }
.form-group
- .form-check
- = provider_gcp_field.check_box :legacy_abac, { class: 'form-check-input' }, false, true
- = provider_gcp_field.label :legacy_abac, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold'
- .form-text.text-muted
- = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
- = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
- = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'role-based-access-control-rbac-core-only'), target: '_blank'
+ = provider_gcp_field.check_box :legacy_abac, { label: s_('ClusterIntegration|RBAC-enabled cluster'),
+ label_class: 'label-bold' }, false, true
+ .form-text.text-muted
+ = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
+ = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md',
+ anchor: 'role-based-access-control-rbac-core-only'), target: '_blank'
.form-group
- = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true
+ = field.submit s_('ClusterIntegration|Create Kubernetes cluster'),
+ class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true
diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml
index 136f98d0126..27b11e8469f 100644
--- a/app/views/clusters/clusters/user/_form.html.haml
+++ b/app/views/clusters/clusters/user/_form.html.haml
@@ -1,40 +1,39 @@
-= form_for @user_cluster, url: clusterable.create_user_clusters_path, as: :cluster do |field|
- = form_errors(@user_cluster)
- .form-group
- = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold'
- = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
+= bootstrap_form_for @user_cluster, html: { class: 'gl-show-field-errors' },
+ url: clusterable.create_user_clusters_path, as: :cluster do |field|
+ = field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'),
+ label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold'
- if has_multiple_clusters?
- .form-group
- = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-bold'
- = field.text_field :environment_scope, class: 'form-control', placeholder: s_('ClusterIntegration|Environment scope')
- .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.")
+ = field.form_group :environment_scope, label: { text: s_('ClusterIntegration|Environment scope'),
+ class: 'label-bold' } do
+ = field.text_field :environment_scope, required: true,
+ title: 'Environment scope is required.', wrapper: false
+ .form-text.text-muted
+ = s_("ClusterIntegration|Choose which of your environments will use this cluster.")
= field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field|
- .form-group
- = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL'), class: 'label-bold'
- = platform_kubernetes_field.text_field :api_url, class: 'form-control', placeholder: s_('ClusterIntegration|API URL')
-
- .form-group
- = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate'), class: 'label-bold'
- = platform_kubernetes_field.text_area :ca_cert, class: 'form-control', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)')
-
- .form-group
- = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token'), class: 'label-bold'
- = platform_kubernetes_field.text_field :token, class: 'form-control', placeholder: s_('ClusterIntegration|Service token'), autocomplete: 'off'
+ = platform_kubernetes_field.url_field :api_url, required: true,
+ title: s_('ClusterIntegration|API URL should be a valid http/https url.'),
+ label: s_('ClusterIntegration|API URL'), label_class: 'label-bold'
+ = platform_kubernetes_field.text_area :ca_cert,
+ placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'),
+ label: s_('ClusterIntegration|CA Certificate'), label_class: 'label-bold'
+ = platform_kubernetes_field.text_field :token, required: true,
+ title: s_('ClusterIntegration|Service token is required.'), label: s_('ClusterIntegration|Service Token'),
+ autocomplete: 'off', label_class: 'label-bold'
- if @user_cluster.allow_user_defined_namespace?
- .form-group
- = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)'), class: 'label-bold'
- = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace')
+ = platform_kubernetes_field.text_field :namespace,
+ label: s_('ClusterIntegration|Project namespace (optional, unique)'), label_class: 'label-bold'
- .form-group
- .form-check
- = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input qa-rbac-checkbox' }, 'rbac', 'abac'
- = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold'
- .form-text.text-muted
- = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
- = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
- = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'role-based-access-control-rbac-core-only'), target: '_blank'
+ = platform_kubernetes_field.form_group :authorization_type do
+ = platform_kubernetes_field.check_box :authorization_type,
+ { class: 'qa-rbac-checkbox', label: s_('ClusterIntegration|RBAC-enabled cluster'),
+ label_class: 'label-bold', inline: true }, 'rbac', 'abac'
+ .form-text.text-muted
+ = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
+ = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md',
+ anchor: 'role-based-access-control-rbac-core-only'), target: '_blank'
.form-group
= field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'btn btn-success'
diff --git a/app/views/clusters/platforms/kubernetes/_form.html.haml b/app/views/clusters/platforms/kubernetes/_form.html.haml
index b5ddca7ccb9..f9f8097cb38 100644
--- a/app/views/clusters/platforms/kubernetes/_form.html.haml
+++ b/app/views/clusters/platforms/kubernetes/_form.html.haml
@@ -1,58 +1,51 @@
-= form_for cluster, url: update_cluster_url_path, as: :cluster do |field|
- = form_errors(cluster)
-
- .form-group
- - if cluster.read_only_kubernetes_platform_fields?
- %label.append-bottom-10{ for: 'cluster-name' }
- = s_('ClusterIntegration|Kubernetes cluster name')
- .input-group
- %input.form-control.cluster-name.js-select-on-focus{ value: cluster.name, readonly: true }
- %span.input-group-append
- = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), class: 'input-group-text btn-default')
- - else
- = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold'
- .input-group
- = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
+= bootstrap_form_for cluster, url: update_cluster_url_path, html: { class: 'gl-show-field-errors' },
+ as: :cluster do |field|
+ - copy_name_btn = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'),
+ class: 'input-group-text btn-default') unless !cluster.read_only_kubernetes_platform_fields?
+ = field.text_field :name, class: 'js-select-on-focus cluster-name', required: true,
+ title: s_('ClusterIntegration|Cluster name is required.'),
+ readonly: cluster.read_only_kubernetes_platform_fields?,
+ label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold',
+ input_group_class: 'gl-field-error-anchor', append: copy_name_btn
= field.fields_for :platform_kubernetes, platform do |platform_field|
- .form-group
- = platform_field.label :api_url, s_('ClusterIntegration|API URL')
- .input-group
- = platform_field.text_field :api_url, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|API URL'), readonly: cluster.read_only_kubernetes_platform_fields?
- - if cluster.read_only_kubernetes_platform_fields?
- %span.input-group-append
- = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'), class: 'input-group-text btn-default')
+ - copy_api_url = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'),
+ class: 'input-group-text btn-default') unless !cluster.read_only_kubernetes_platform_fields?
+ = platform_field.text_field :api_url, class: 'js-select-on-focus', required: true,
+ title: s_('ClusterIntegration|API URL should be a valid http/https url.'),
+ readonly: cluster.read_only_kubernetes_platform_fields?,
+ label: s_('ClusterIntegration|API URL'), label_class: 'label-bold',
+ input_group_class: 'gl-field-error-anchor', append: copy_api_url
+
+ - copy_ca_cert_btn = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'),
+ class: 'input-group-text btn-default') unless !cluster.read_only_kubernetes_platform_fields?
+ = platform_field.text_area :ca_cert, class: 'js-select-on-focus', rows: '5',
+ readonly: cluster.read_only_kubernetes_platform_fields?,
+ placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'),
+ label: s_('ClusterIntegration|CA Certificate'), label_class: 'label-bold',
+ input_group_class: 'gl-field-error-anchor', append: copy_ca_cert_btn
- .form-group
- = platform_field.label :ca_cert, s_('ClusterIntegration|CA Certificate')
- .input-group
- = platform_field.text_area :ca_cert, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), readonly: cluster.read_only_kubernetes_platform_fields?
- - if cluster.read_only_kubernetes_platform_fields?
- %span.input-group-append.clipboard-addon
- = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), class: 'input-group-text btn-blank')
+ - show_token_btn = (platform_field.button s_('ClusterIntegration|Show'),
+ type: 'button', class: 'js-show-cluster-token btn btn-default')
+ - copy_token_btn = clipboard_button(text: platform.token, title: s_('ClusterIntegration|Copy Service Token'),
+ class: 'input-group-text btn-default') unless !cluster.read_only_kubernetes_platform_fields?
- .form-group
- = platform_field.label :token, s_('ClusterIntegration|Token')
- .input-group
- = platform_field.text_field :token, class: 'form-control js-cluster-token js-select-on-focus', type: 'password', placeholder: s_('ClusterIntegration|Token'), readonly: cluster.read_only_kubernetes_platform_fields?
- %span.input-group-append
- %button.btn.btn-default.input-group-text.js-show-cluster-token{ type: 'button' }
- = s_('ClusterIntegration|Show')
- - if cluster.read_only_kubernetes_platform_fields?
- = clipboard_button(text: platform.token, title: s_('ClusterIntegration|Copy Token'), class: 'btn-default')
+ = platform_field.text_field :token, type: 'password', class: 'js-select-on-focus js-cluster-token',
+ required: true, title: s_('ClusterIntegration|Service token is required.'),
+ readonly: cluster.read_only_kubernetes_platform_fields?,
+ label: s_('ClusterIntegration|Service Token'), label_class: 'label-bold',
+ input_group_class: 'gl-field-error-anchor', append: show_token_btn + copy_token_btn
- if cluster.allow_user_defined_namespace?
- .form-group
- = platform_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)')
- = platform_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace')
+ = platform_field.text_field :namespace, label: s_('ClusterIntegration|Project namespace (optional, unique)'),
+ label_class: 'label-bold'
- .form-group
- .form-check
- = platform_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac'
- = platform_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold'
- .form-text.text-muted
- = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
- = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
+ = platform_field.form_group :authorization_type do
+ = platform_field.check_box :authorization_type, { disabled: true, label: s_('ClusterIntegration|RBAC-enabled cluster'),
+ label_class: 'label-bold', inline: true }, 'rbac', 'abac'
+ .form-text.text-muted
+ = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
+ = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
.form-group
= field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success'
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index 51dcc9d0cda..6269678079a 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -27,7 +27,7 @@
.form-group.group-description-holder.col-sm-12
= f.label :avatar, _("Group avatar"), class: 'label-bold'
%div
- = render 'shared/choose_group_avatar_button', f: f
+ = render 'shared/choose_avatar_button', f: f
.form-group.col-sm-12
%label.label-bold
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index 9ed71d19d32..c382a1ed168 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -23,10 +23,10 @@
.avatar-container.rect-avatar.s90
= group_icon(@group, alt: '', class: 'avatar group-avatar s90')
= f.label :avatar, _('Group avatar'), class: 'label-bold d-block'
- = render 'shared/choose_group_avatar_button', f: f
+ = render 'shared/choose_avatar_button', f: f
- if @group.avatar?
%hr
- = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-danger btn-inverted'
+ = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
= render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 1b2a4cd6780..26a1f1e119c 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -7,6 +7,7 @@
.alert-wrapper
= render "layouts/broadcast"
= render "layouts/header/read_only_banner"
+ = render "layouts/nav/classification_level_banner"
= yield :flash_message
= render "shared/ping_consent"
- unless @hide_breadcrumbs
diff --git a/app/views/layouts/nav/_classification_level_banner.html.haml b/app/views/layouts/nav/_classification_level_banner.html.haml
new file mode 100644
index 00000000000..cc4caf079b8
--- /dev/null
+++ b/app/views/layouts/nav/_classification_level_banner.html.haml
@@ -0,0 +1,5 @@
+- if ::Gitlab::ExternalAuthorization.enabled? && @project
+ = content_for :header_content do
+ %span.badge.color-label.classification-label.has-tooltip{ title: s_('ExternalAuthorizationService|Classification label') }
+ = sprite_icon('lock-open', size: 8, css_class: 'inline')
+ = @project.external_authorization_classification_label
diff --git a/app/views/projects/_classification_policy_settings.html.haml b/app/views/projects/_classification_policy_settings.html.haml
new file mode 100644
index 00000000000..57c7a718d53
--- /dev/null
+++ b/app/views/projects/_classification_policy_settings.html.haml
@@ -0,0 +1,8 @@
+- if ::Gitlab::ExternalAuthorization.enabled?
+ .form-group
+ = f.label :external_authorization_classification_label, class: 'label-bold' do
+ = s_('ExternalAuthorizationService|Classification Label')
+ %span.light (optional)
+ = f.text_field :external_authorization_classification_label, class: "form-control"
+ %span.form-text.text-muted
+ = external_classification_label_help_message
diff --git a/app/views/projects/blob/viewers/_route_map.html.haml b/app/views/projects/blob/viewers/_route_map.html.haml
index 6d6bd79bc3c..07b9378ba97 100644
--- a/app/views/projects/blob/viewers/_route_map.html.haml
+++ b/app/views/projects/blob/viewers/_route_map.html.haml
@@ -6,4 +6,4 @@
This Route Map is invalid:
= viewer.validation_message
-= link_to 'Learn more', help_page_path('ci/environments', anchor: 'go-directly-from-source-files-to-public-pages-on-the-environment')
+= link_to 'Learn more', help_page_path('ci/environments', anchor: 'going-from-source-files-to-public-pages')
diff --git a/app/views/projects/blob/viewers/_route_map_loading.html.haml b/app/views/projects/blob/viewers/_route_map_loading.html.haml
index a5f73fb0197..f11c047e85a 100644
--- a/app/views/projects/blob/viewers/_route_map_loading.html.haml
+++ b/app/views/projects/blob/viewers/_route_map_loading.html.haml
@@ -1,4 +1,4 @@
= icon('spinner spin fw')
Validating Route Map…
-= link_to 'Learn more', help_page_path('ci/environments', anchor: 'go-directly-from-source-files-to-public-pages-on-the-environment')
+= link_to 'Learn more', help_page_path('ci/environments', anchor: 'going-from-source-files-to-public-pages')
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index 4eb53faa6ff..acd63de2277 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -8,30 +8,20 @@
%span.sr-only= _('Select Archive Format')
= sprite_icon("arrow-down")
%ul.dropdown-menu.dropdown-menu-right{ role: 'menu' }
- %li.dropdown-header
- #{ _('Source code') }
- %li
- = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'zip'), rel: 'nofollow', download: '' do
- %span= _('Download zip')
- %li
- = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.gz'), rel: 'nofollow', download: '' do
- %span= _('Download tar.gz')
- %li
- = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.bz2'), rel: 'nofollow', download: '' do
- %span= _('Download tar.bz2')
- %li
- = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar'), rel: 'nofollow', download: '' do
- %span= _('Download tar')
-
+ %li.dropdown-bold-header= _('Download source code')
+ %li.dropdown-menu-content
+ = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil
+ - if directory?
+ %li.separator
+ %li.dropdown-bold-header= _('Download this directory')
+ %li.dropdown-menu-content
+ = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path
- if pipeline && pipeline.latest_builds_with_artifacts.any?
- %li.dropdown-header Artifacts
+ %li.separator
+ %li.dropdown-bold-header= _('Download artifacts')
- unless pipeline.latest?
- - latest_pipeline = project.pipeline_for(ref)
- %li
- .unclickable= ci_status_for_statuseable(latest_pipeline)
- %li.dropdown-header Previous Artifacts
+ %span.unclickable= ci_status_for_statuseable(project.pipeline_for(ref))
+ %li.dropdown-header= _('Previous Artifacts')
- pipeline.latest_builds_with_artifacts.each do |job|
%li
- = link_to latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' do
- %span
- #{s_('DownloadArtifacts|Download')} '#{job.name}'
+ = link_to job.name, latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: ''
diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml
new file mode 100644
index 00000000000..47a1704f946
--- /dev/null
+++ b/app/views/projects/buttons/_download_links.html.haml
@@ -0,0 +1,9 @@
+%ul
+ %li.d-inline-block.m-0.p-0
+ = link_to 'zip', project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: 'zip'), rel: 'nofollow', download: '', class: 'btn btn-primary btn-xs'
+ %li.d-inline-block.m-0.p-0
+ = link_to 'tar.gz', project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: 'tar.gz'), rel: 'nofollow', download: '', class: 'btn btn-xs'
+ %li.d-inline-block.m-0.p-0
+ = link_to 'tar.bz2', project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: 'tar.bz2'), rel: 'nofollow', download: '', class: 'btn btn-xs'
+ %li.d-inline-block.m-0.p-0
+ = link_to 'tar', project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: 'tar'), rel: 'nofollow', download: '', class: 'btn btn-xs'
diff --git a/app/views/projects/diffs/_replaced_image_diff.html.haml b/app/views/projects/diffs/_replaced_image_diff.html.haml
index 6dffc7c4390..566dfe798c6 100644
--- a/app/views/projects/diffs/_replaced_image_diff.html.haml
+++ b/app/views/projects/diffs/_replaced_image_diff.html.haml
@@ -35,10 +35,10 @@
.swipe.view.hide
.swipe-frame
- .frame.deleted
+ .frame.deleted.old-diff
= image_tag(old_blob_raw_url, alt: diff_file.old_path, lazy: false)
- .swipe-wrap
- = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.new_path }
+ .swipe-wrap.left-oriented
+ = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added old-diff js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.new_path }
%span.swipe-bar
%span.top-handle
%span.bottom-handle
diff --git a/app/views/projects/diffs/_single_image_diff.html.haml b/app/views/projects/diffs/_single_image_diff.html.haml
index 454f814795a..daac543b939 100644
--- a/app/views/projects/diffs/_single_image_diff.html.haml
+++ b/app/views/projects/diffs/_single_image_diff.html.haml
@@ -10,5 +10,5 @@
.image.js-single-image{ data: diff_view_data }
.wrap
- single_class_name = diff_file.deleted_file? ? 'deleted' : 'added'
- = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "#{single_class_name} #{class_name} js-image-frame", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.file_path }
+ = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "#{single_class_name} #{class_name} old-diff js-image-frame", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.file_path }
%p.image-info= number_to_human_size(blob.size)
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index a6a8ca489a9..abf2fb7dc57 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -32,7 +32,7 @@
%span.light (optional)
= f.text_area :description, class: "form-control", rows: 3, maxlength: 250
- = render_if_exists 'projects/classification_policy_settings', f: f
+ = render 'projects/classification_policy_settings', f: f
= render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project
@@ -40,23 +40,16 @@
= f.label :tag_list, "Topics", class: 'label-bold'
= f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control"
%p.form-text.text-muted Separate topics with commas.
- %fieldset.features
- %h5.prepend-top-0= _("Project avatar")
- .form-group
- - if @project.avatar?
- .avatar-container.rect-avatar.s160.append-bottom-15
- = project_icon(@project, alt: '', class: 'avatar project-avatar s160', width: 160, height: 160)
- - if @project.avatar_in_git
- %p.light
- = _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git }
- .prepend-top-5.append-bottom-10
- %button.btn.js-choose-project-avatar-button{ type: 'button' }= _("Choose file...")
- %span.file_name.prepend-left-default.js-filename= _("No file chosen")
- = f.file_field :avatar, class: "js-project-avatar-input hidden"
- .form-text.text-muted= _("The maximum file size allowed is 200KB.")
- - if @project.avatar?
- %hr
- = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _("Avatar will be removed. Are you sure?") }, method: :delete, class: "btn btn-danger btn-inverted"
+
+ .form-group.prepend-top-default.append-bottom-20
+ .avatar-container.s90
+ = project_icon(@project, alt: _('Project avatar'), class: 'avatar project-avatar s90')
+ = f.label :avatar, _('Project avatar'), class: 'label-bold d-block'
+ = render 'shared/choose_avatar_button', f: f
+ - if @project.avatar?
+ %hr
+ = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
+
= f.submit 'Save changes', class: "btn btn-success js-btn-success-general-project-settings"
%section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded) }
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index 475bae887ec..81a53f22f67 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -8,6 +8,7 @@
%div{ class: container_class }
#js-job-vue-app{ data: { endpoint: project_job_path(@project, @build, format: :json),
+ deployment_help_url: help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting-failed-deployment-jobs'),
runner_help_url: help_page_path('ci/runners/README.html', anchor: 'setting-maximum-job-timeout-for-a-runner'),
runner_settings_url: project_runners_path(@build.project, anchor: 'js-runners-settings'),
build_options: javascript_build_options } }
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index 9da42fe99ac..4d1d078661d 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -2,10 +2,6 @@
- page_title _('CI / CD Charts')
%div{ class: container_class }
- .sub-header-block
- .oneline
- = _("A collection of graphs regarding Continuous Integration")
-
#charts.ci-charts
.row
.col-md-6
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index fac68a36e79..fe74dc122c3 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -17,10 +17,11 @@
= s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.')
= link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank'
.card-footer.js-extra-settings{ class: auto_devops_enabled || 'hidden' }
- %p.settings-message.text-center
- - kubernetes_cluster_link = help_page_path('user/project/clusters/index')
- - kubernetes_cluster_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: kubernetes_cluster_link }
- = s_('CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_start: kubernetes_cluster_start, kubernetes_cluster_end: '</a>'.html_safe }
+ - if @project.all_clusters.empty?
+ %p.settings-message.text-center
+ - kubernetes_cluster_link = help_page_path('user/project/clusters/index')
+ - kubernetes_cluster_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: kubernetes_cluster_link }
+ = s_('CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_start: kubernetes_cluster_start, kubernetes_cluster_end: '</a>'.html_safe }
%label.prepend-top-10
%strong= s_('CICD|Deployment strategy')
.form-check
diff --git a/app/views/projects/wikis/pages.html.haml b/app/views/projects/wikis/pages.html.haml
index 94267b6e0cf..77fdf7f001c 100644
--- a/app/views/projects/wikis/pages.html.haml
+++ b/app/views/projects/wikis/pages.html.haml
@@ -2,6 +2,7 @@
- add_to_breadcrumbs "Wiki", project_wiki_path(@project, :home)
- breadcrumb_title s_("Wiki|Pages")
- page_title s_("Wiki|Pages"), _("Wiki")
+- sort_title = wiki_sort_title(params[:sort])
%div{ class: container_class }
.wiki-page-header
@@ -15,6 +16,18 @@
= icon('cloud-download')
= _("Clone repository")
+ .dropdown.inline.wiki-sort-dropdown
+ .btn-group{ role: 'group' }
+ .btn-group{ role: 'group' }
+ %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
+ = sort_title
+ = icon('chevron-down')
+ %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
+ %li
+ = sortable_item(s_("Wiki|Title"), project_wikis_pages_path(@project, sort: ProjectWiki::TITLE_ORDER), sort_title)
+ = sortable_item(s_("Wiki|Created date"), project_wikis_pages_path(@project, sort: ProjectWiki::CREATED_AT_ORDER), sort_title)
+ = wiki_sort_controls(@project, params[:sort], params[:direction])
+
%ul.wiki-pages-list.content-list
= render @wiki_entries, context: 'pages'
diff --git a/app/views/shared/_choose_avatar_button.html.haml b/app/views/shared/_choose_avatar_button.html.haml
new file mode 100644
index 00000000000..0d46d047134
--- /dev/null
+++ b/app/views/shared/_choose_avatar_button.html.haml
@@ -0,0 +1,4 @@
+%button.btn.js-choose-avatar-button{ type: 'button' }= _("Choose file…")
+%span.file_name.js-avatar-filename= _("No file chosen")
+= f.file_field :avatar, class: "js-avatar-input hidden"
+.form-text.text-muted= _("The maximum file size allowed is 200KB.")
diff --git a/app/views/shared/_choose_group_avatar_button.html.haml b/app/views/shared/_choose_group_avatar_button.html.haml
deleted file mode 100644
index 0552fe62090..00000000000
--- a/app/views/shared/_choose_group_avatar_button.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-%button.btn.js-choose-group-avatar-button{ type: 'button' }= _("Choose File ...")
-%span.file_name.js-avatar-filename= _("No file chosen")
-= f.file_field :avatar, class: "js-group-avatar-input hidden"
-.form-text.text-muted= _("The maximum file size allowed is 200KB.")
diff --git a/app/views/shared/_delete_label_modal.html.haml b/app/views/shared/_delete_label_modal.html.haml
index b96380923ac..dbd3bbb43af 100644
--- a/app/views/shared/_delete_label_modal.html.haml
+++ b/app/views/shared/_delete_label_modal.html.haml
@@ -2,7 +2,7 @@
.modal-dialog
.modal-content
.modal-header
- %h3.page-title Delete #{render_colored_label(label, tooltip: false)} ?
+ %h3.page-title Delete #{render_label(label, tooltip: false)} ?
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index c5ea15a7f63..6651f12f6de 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -7,7 +7,7 @@
- if defined?(@project)
= link_to_label(label, subject: @project, tooltip: false)
- else
- = render_colored_label(label, tooltip: false)
+ = render_label(label, tooltip: false)
.label-description
.append-right-default.prepend-left-default
- if label.description.present?
diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml
index 19159684420..c6eade3bbbc 100644
--- a/app/views/shared/boards/components/sidebar/_labels.html.haml
+++ b/app/views/shared/boards/components/sidebar/_labels.html.haml
@@ -21,13 +21,7 @@
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button",
":data-selected" => "selectedLabels",
":data-labels" => "issue.assignableLabelsEndpoint",
- data: { toggle: "dropdown",
- field_name: "issue[label_names][]",
- show_no: "true",
- show_any: "true",
- project_id: @project&.try(:id),
- namespace_path: @namespace_path,
- project_path: @project.try(:path) } }
+ data: label_dropdown_data(@project, namespace_path: @namespace_path, field_name: "issue[label_names][]") }
%span.dropdown-toggle-text
{{ labelDropdownTitle }}
= icon('chevron-down')
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index 25df2fe5cd6..b11cb8a3076 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -5,7 +5,7 @@
- supports_quick_actions = model.new_record?
- if supports_quick_actions
- - preview_url = preview_markdown_path(project, quick_actions_target_type: model.class.name)
+ - preview_url = preview_markdown_path(project, target_type: model.class.name)
- else
- preview_url = preview_markdown_path(project)
diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml
index d5fb85ba0f3..f2c0c77a583 100644
--- a/app/views/shared/issuable/_label_dropdown.html.haml
+++ b/app/views/shared/issuable/_label_dropdown.html.haml
@@ -8,7 +8,7 @@
- classes = local_assigns.fetch(:classes, [])
- selected = local_assigns.fetch(:selected, nil)
- dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label")
-- dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), labels: labels_filter_path_with_defaults, default_label: "Labels"}
+- dropdown_data = label_dropdown_data(@project, labels: labels_filter_path_with_defaults, default_label: "Labels")
- dropdown_data.merge!(data_options)
- label_name = local_assigns.fetch(:label_name, "Labels")
- no_default_styles = local_assigns.fetch(:no_default_styles, false)
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 9596c1df20e..0798b1da4b7 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -105,10 +105,8 @@
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right'
.value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) }
- if selected_labels.any?
- - selected_labels.each do |label|
- = link_to sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label[:title]) do
- %span.badge.color-label.has-tooltip{ style: "background-color: #{label[:color]}; color: #{label[:text_color]}", title: label[:description], data: { container: "body" } }
- = label[:title]
+ - selected_labels.each do |label_hash|
+ = render_label(label_from_hash(label_hash), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]))
- else
%span.no-value
= _('None')
@@ -116,7 +114,7 @@
- selected_labels.each do |label|
= hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil
.dropdown
- %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable_type}[label_names][]", ability_name: issuable_type, show_no: "true", show_any: "true", namespace_path: issuable_sidebar[:namespace_path], project_path: issuable_sidebar[:project_path], issue_update: issuable_sidebar[:issuable_json_path], labels: issuable_sidebar[:project_labels_path], display: 'static' } }
+ %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: sidebar_label_dropdown_data(issuable_type, issuable_sidebar) }
%span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
= multi_label_name(selected_labels, "Labels")
= icon('chevron-down', 'aria-hidden': 'true')
diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml
index 7619d0a2e9c..743ee1435e8 100644
--- a/app/views/shared/labels/_form.html.haml
+++ b/app/views/shared/labels/_form.html.haml
@@ -4,7 +4,9 @@
.form-group.row
= f.label :title, class: 'col-form-label col-sm-2'
.col-sm-10
- = f.text_field :title, class: "form-control qa-label-title", required: true, autofocus: true
+ = f.text_field :title, class: "form-control js-label-title qa-label-title", required: true, autofocus: true
+ = render_if_exists 'shared/labels/create_label_help_text'
+
.form-group.row
= f.label :description, class: 'col-form-label col-sm-2'
.col-sm-10
diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml
index eba64daaadc..5863f52aa78 100644
--- a/app/views/shared/milestones/_issuable.html.haml
+++ b/app/views/shared/milestones/_issuable.html.haml
@@ -21,8 +21,7 @@
%span.issuable-number= issuable.to_reference
- labels.each do |label|
- = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }) do
- - render_colored_label(label)
+ = render_label(label, link: polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }))
%span.assignee-icon
- assignees.each do |assignee|
diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml
index 6797520650d..6b0640bd8cb 100644
--- a/app/views/shared/milestones/_labels_tab.html.haml
+++ b/app/views/shared/milestones/_labels_tab.html.haml
@@ -5,8 +5,7 @@
%li.is-not-draggable
%span.label-row
%span.label-name
- = link_to milestones_label_path(options) do
- - render_colored_label(label, tooltip: false)
+ = render_label(label, tooltip: false, link: milestones_label_path(options))
%span.prepend-description-left
= markdown_field(label, :description)
diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml
index 6a1eea85fde..d91bc6e57c9 100644
--- a/app/views/shared/notes/_form.html.haml
+++ b/app/views/shared/notes/_form.html.haml
@@ -1,7 +1,7 @@
- supports_autocomplete = local_assigns.fetch(:supports_autocomplete, true)
- supports_quick_actions = note_supports_quick_actions?(@note)
- if supports_quick_actions
- - preview_url = preview_markdown_path(@project, quick_actions_target_type: @note.noteable_type, quick_actions_target_id: @note.noteable_id)
+ - preview_url = preview_markdown_path(@project, target_type: @note.noteable_type, target_id: @note.noteable_id)
- else
- preview_url = preview_markdown_path(@project)
diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb
index 9ec8bcca4f3..b30864db802 100644
--- a/app/workers/reactive_caching_worker.rb
+++ b/app/workers/reactive_caching_worker.rb
@@ -3,7 +3,6 @@
class ReactiveCachingWorker
include ApplicationWorker
- # rubocop: disable CodeReuse/ActiveRecord
def perform(class_name, id, *args)
klass = begin
class_name.constantize
@@ -12,7 +11,9 @@ class ReactiveCachingWorker
end
return unless klass
- klass.find_by(klass.primary_key => id).try(:exclusively_update_reactive_cache!, *args)
+ klass
+ .reactive_cache_worker_finder
+ .call(id, *args)
+ .try(:exclusively_update_reactive_cache!, *args)
end
- # rubocop: enable CodeReuse/ActiveRecord
end