diff options
Diffstat (limited to 'app')
191 files changed, 2446 insertions, 1031 deletions
diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js index 417ac31fc86..81c89441424 100644 --- a/app/assets/javascripts/behaviors/toggler_behavior.js +++ b/app/assets/javascripts/behaviors/toggler_behavior.js @@ -12,7 +12,7 @@ $(() => { const $container = $(container); $container - .find('.js-toggle-button .fa') + .find('.js-toggle-button .fa-chevron-up, .js-toggle-button .fa-chevron-down') .toggleClass('fa-chevron-up', toggleState) .toggleClass('fa-chevron-down', toggleState !== undefined ? !toggleState : undefined); @@ -22,7 +22,7 @@ $(() => { } $('body').on('click', '.js-toggle-button', function toggleButton(e) { - e.target.classList.toggle('open'); + e.currentTarget.classList.toggle(e.currentTarget.dataset.toggleOpenClass || 'open'); toggleContainer($(this).closest('.js-toggle-container')); const targetTag = e.currentTarget.tagName.toLowerCase(); diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 23fec503586..84885ca9306 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -1,4 +1,5 @@ <script> +/* eslint-disable vue/require-default-prop */ import './issue_card_inner'; import eventHub from '../eventhub'; @@ -34,6 +35,9 @@ export default { type: String, default: '', }, + groupId: { + type: Number, + }, }, data() { return { @@ -88,6 +92,7 @@ export default { :list="list" :issue="issue" :issue-link-base="issueLinkBase" + :group-id="groupId" :root-path="rootPath" :update-filters="true" /> diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 6637904d87d..0d03c1c419c 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -15,6 +15,11 @@ export default { loadingIcon, }, props: { + groupId: { + type: Number, + required: false, + default: 0, + }, disabled: { type: Boolean, required: true, @@ -170,6 +175,7 @@ export default { <loading-icon /> </div> <board-new-issue + :group-id="groupId" :list="list" v-if="list.type !== 'closed' && showIssueForm"/> <ul @@ -185,6 +191,7 @@ export default { :list="list" :issue="issue" :issue-link-base="issueLinkBase" + :group-id="groupId" :root-path="rootPath" :disabled="disabled" :key="issue.id" /> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index efface7143d..870d242e774 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -1,12 +1,21 @@ <script> import eventHub from '../eventhub'; +import ProjectSelect from './project_select.vue'; import ListIssue from '../models/issue'; const Store = gl.issueBoards.BoardsStore; export default { name: 'BoardNewIssue', + components: { + ProjectSelect, + }, props: { + groupId: { + type: Number, + required: false, + default: 0, + }, list: { type: Object, required: true, @@ -16,10 +25,20 @@ export default { return { title: '', error: false, + selectedProject: {}, }; }, + computed: { + disabled() { + if (this.groupId) { + return this.title === '' || !this.selectedProject.name; + } + return this.title === ''; + }, + }, mounted() { this.$refs.input.focus(); + eventHub.$on('setSelectedProject', this.setSelectedProject); }, methods: { submit(e) { @@ -34,6 +53,7 @@ export default { labels, subscribed: true, assignees: [], + project_id: this.selectedProject.id, }); eventHub.$emit(`scroll-board-list-${this.list.id}`); @@ -62,52 +82,62 @@ export default { this.title = ''; eventHub.$emit(`hide-issue-form-${this.list.id}`); }, + setSelectedProject(selectedProject) { + this.selectedProject = selectedProject; + }, }, }; </script> <template> - <div class="card board-new-issue-form"> - <form @submit="submit($event)"> - <div - class="flash-container" - v-if="error" - > - <div class="flash-alert"> - An error occurred. Please try again. - </div> - </div> - <label - class="label-light" - :for="list.id + '-title'" - > - Title - </label> - <input - class="form-control" - type="text" - v-model="title" - ref="input" - autocomplete="off" - :id="list.id + '-title'" - /> - <div class="clearfix prepend-top-10"> - <button - class="btn btn-success pull-left" - type="submit" - :disabled="title === ''" - ref="submit-button" + <div class="board-new-issue-form"> + <div class="card"> + <form @submit="submit($event)"> + <div + class="flash-container" + v-if="error" > - Submit issue - </button> - <button - class="btn btn-default pull-right" - type="button" - @click="cancel" + <div class="flash-alert"> + An error occurred. Please try again. + </div> + </div> + <label + class="label-light" + :for="list.id + '-title'" > - Cancel - </button> - </div> - </form> + Title + </label> + <input + class="form-control" + type="text" + v-model="title" + ref="input" + autocomplete="off" + :id="list.id + '-title'" + /> + <project-select + v-if="groupId" + :group-id="groupId" + /> + <div class="clearfix prepend-top-10"> + <button + class="btn btn-success pull-left" + type="submit" + :disabled="disabled" + ref="submit-button" + > + Submit issue + </button> + <button + class="btn btn-default pull-right" + type="button" + @click="cancel" + > + Cancel + </button> + </div> + </form> + </div> </div> </template> + diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index bf474879024..fc2bad2415f 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -31,6 +31,10 @@ gl.issueBoards.IssueCardInner = Vue.extend({ required: false, default: false, }, + groupId: { + type: Number, + required: false, + }, }, data() { return { @@ -64,7 +68,13 @@ gl.issueBoards.IssueCardInner = Vue.extend({ return this.issue.assignees.length > this.numberOverLimit; }, cardUrl() { - return `${this.issueLinkBase}/${this.issue.iid}`; + let baseUrl = this.issueLinkBase; + + if (this.groupId && this.issue.project) { + baseUrl = this.issueLinkBase.replace(':project_path', this.issue.project.path); + } + + return `${baseUrl}/${this.issue.iid}`; }, issueId() { if (this.issue.iid) { @@ -148,7 +158,7 @@ gl.issueBoards.IssueCardInner = Vue.extend({ class="card-number" v-if="issueId" > - {{ issueId }} + <template v-if="groupId && issue.project">{{issue.project.path}}</template>{{ issueId }} </span> </h4> <div class="card-assignee"> diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue new file mode 100644 index 00000000000..d99b222c305 --- /dev/null +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -0,0 +1,127 @@ +<script> + /* global ListIssue */ + import _ from 'underscore'; + import eventHub from '../eventhub'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + import Api from '../../api'; + + export default { + name: 'BoardProjectSelect', + components: { + loadingIcon, + }, + props: { + groupId: { + type: Number, + required: true, + default: 0, + }, + }, + data() { + return { + loading: true, + selectedProject: {}, + }; + }, + computed: { + selectedProjectName() { + return this.selectedProject.name || 'Select a project'; + }, + }, + mounted() { + $(this.$refs.projectsDropdown).glDropdown({ + filterable: true, + filterRemote: true, + search: { + fields: ['name_with_namespace'], + }, + clicked: ({ $el, e }) => { + e.preventDefault(); + this.selectedProject = { + id: $el.data('project-id'), + name: $el.data('project-name'), + }; + eventHub.$emit('setSelectedProject', this.selectedProject); + }, + selectable: true, + data: (term, callback) => { + this.loading = true; + return Api.groupProjects(this.groupId, term, (projects) => { + this.loading = false; + callback(projects); + }); + }, + renderRow(project) { + return ` + <li> + <a href='#' class='dropdown-menu-link' data-project-id="${project.id}" data-project-name="${project.name}"> + ${_.escape(project.name)} + </a> + </li> + `; + }, + text: project => project.name, + }); + }, + }; +</script> + +<template> + <div> + <label class="label-light prepend-top-10"> + Project + </label> + <div + ref="projectsDropdown" + class="dropdown" + > + <button + class="dropdown-menu-toggle wide" + type="button" + data-toggle="dropdown" + aria-expanded="false" + > + {{ selectedProjectName }} + <i + class="fa fa-chevron-down" + aria-hidden="true" + > + </i> + </button> + <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width"> + <div class="dropdown-title"> + <span>Projects</span> + <button + aria-label="Close" + type="button" + class="dropdown-title-button dropdown-menu-close" + > + <i + aria-hidden="true" + data-hidden="true" + class="fa fa-times dropdown-menu-close-icon" + > + </i> + </button> + </div> + <div class="dropdown-input"> + <input + class="dropdown-input-field" + type="search" + placeholder="Search projects" + /> + <i + aria-hidden="true" + data-hidden="true" + class="fa fa-search dropdown-input-search" + > + </i> + </div> + <div class="dropdown-content"></div> + <div class="dropdown-loading"> + <loading-icon /> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.js b/app/assets/javascripts/boards/components/sidebar/remove_issue.js index 0ae32bb4d0a..09c683ff621 100644 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.js +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.js @@ -24,7 +24,7 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({ }, computed: { updateUrl() { - return this.issueUpdate; + return this.issueUpdate.replace(':project_path', this.issue.project.path); }, }, methods: { @@ -32,17 +32,21 @@ gl.issueBoards.RemoveIssueBtn = Vue.extend({ const issue = this.issue; const lists = issue.getLists(); const listLabelIds = lists.map(list => list.label.id); - let labelIds = this.issue.labels + + let labelIds = issue.labels .map(label => label.id) .filter(id => !listLabelIds.includes(id)); if (labelIds.length === 0) { labelIds = ['']; } + const data = { issue: { label_ids: labelIds, }, }; + + // Post the remove data Vue.http.patch(this.updateUrl, data).catch(() => { Flash(__('Failed to remove issue from board, please try again.')); diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 57a7cc4ca30..fb40b9f5565 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -6,6 +6,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager { constructor(store, updateUrl = false, cantEdit = []) { super({ page: 'boards', + stateFiltersSelector: '.issues-state-filters', }); this.store = store; diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 8b34fe232c2..efc0da2e7a2 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -13,6 +13,7 @@ import sidebarEventHub from '~/sidebar/event_hub'; // eslint-disable-line import import './models/issue'; import './models/list'; import './models/milestone'; +import './models/project'; import './models/assignee'; import './stores/boards_store'; import './stores/modal_store'; @@ -89,7 +90,7 @@ export default () => { sidebarEventHub.$off('toggleSubscription', this.toggleSubscription); }, mounted () { - this.filterManager = new FilteredSearchBoards(Store.filter, true); + this.filterManager = new FilteredSearchBoards(Store.filter, true, Store.cantEdit); this.filterManager.setup(); Store.disabled = this.disabled; @@ -179,6 +180,7 @@ export default () => { return { modal: ModalStore.store, store: Store.state, + canAdminList: this.$options.el.hasAttribute('data-can-admin-list'), }; }, computed: { @@ -232,6 +234,7 @@ export default () => { :class="{ 'disabled': disabled }" :title="tooltipTitle" :aria-disabled="disabled" + v-if="canAdminList" @click="openModal"> Add issues </button> diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js index 38a0eb12f92..5e31c6314b2 100644 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -1,6 +1,8 @@ /* eslint-disable no-unused-vars, no-mixed-operators, comma-dangle */ /* global DocumentTouch */ +import sortableConfig from '../../sortable/sortable_config'; + window.gl = window.gl || {}; window.gl.issueBoards = window.gl.issueBoards || {}; @@ -18,19 +20,14 @@ gl.issueBoards.onEnd = () => { gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch; gl.issueBoards.getBoardSortableDefaultOptions = (obj) => { - const defaultSortOptions = { - animation: 200, - forceFallback: true, - fallbackClass: 'is-dragging', - fallbackOnBody: true, - ghostClass: 'is-ghost', + const defaultSortOptions = Object.assign({}, sortableConfig, { filter: '.board-delete, .btn', delay: gl.issueBoards.touchEnabled ? 100 : 0, scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100, scrollSpeed: 20, onStart: gl.issueBoards.onStart, - onEnd: gl.issueBoards.onEnd - }; + onEnd: gl.issueBoards.onEnd, + }); Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; }); return defaultSortOptions; diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index 3bfb6d39ad5..4c5079efc8b 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -4,6 +4,7 @@ /* global ListAssignee */ import Vue from 'vue'; +import IssueProject from './project'; class ListIssue { constructor (obj, defaultAvatar) { @@ -23,6 +24,12 @@ class ListIssue { this.isLoading = {}; this.sidebarInfoEndpoint = obj.issue_sidebar_endpoint; this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; + this.milestone_id = obj.milestone_id; + this.project_id = obj.project_id; + + if (obj.project) { + this.project = new IssueProject(obj.project); + } if (obj.milestone) { this.milestone = new ListMilestone(obj.milestone); @@ -105,7 +112,8 @@ class ListIssue { data.issue.label_ids = ['']; } - return Vue.http.patch(url, data); + const projectPath = this.project ? this.project.path : ''; + return Vue.http.patch(url.replace(':project_path', projectPath), data); } } diff --git a/app/assets/javascripts/boards/models/project.js b/app/assets/javascripts/boards/models/project.js new file mode 100644 index 00000000000..a3d5c7af7ac --- /dev/null +++ b/app/assets/javascripts/boards/models/project.js @@ -0,0 +1,6 @@ +export default class IssueProject { + constructor(obj) { + this.id = obj.id; + this.path = obj.path; + } +} diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 1325a268214..f8dcdf3f60a 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -117,7 +117,10 @@ </script> <template> - <section class="settings no-animate expanded"> + <section + id="cluster-applications" + class="settings no-animate expanded" + > <div class="settings-header"> <h4> {{ s__('ClusterIntegration|Applications') }} @@ -183,7 +186,7 @@ <clipboard-button :text="ingressExternalIp" :title="s__('ClusterIntegration|Copy Ingress IP Address to clipboard')" - css-class="btn btn-default js-clipboard-btn" + class="js-clipboard-btn" /> </span> </div> diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index ce19069f103..466a5b5d635 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -20,10 +20,6 @@ type: String, required: true, }, - emptyStateSvgPath: { - type: String, - required: true, - }, errorStateSvgPath: { type: String, required: true, @@ -45,23 +41,14 @@ }, computed: { - /** - * Empty state is only rendered if after the first request we receive no pipelines. - * - * @return {Boolean} - */ - shouldRenderEmptyState() { - return !this.state.pipelines.length && - !this.isLoading && - this.hasMadeRequest && - !this.hasError; - }, - shouldRenderTable() { return !this.isLoading && this.state.pipelines.length > 0 && !this.hasError; }, + shouldRenderErrorState() { + return this.hasError && !this.isLoading; + }, }, created() { this.service = new PipelinesService(this.endpoint); @@ -92,25 +79,22 @@ <div class="content-list pipelines"> <loading-icon - label="Loading pipelines" + :label="s__('Pipelines|Loading Pipelines')" size="3" v-if="isLoading" + class="prepend-top-20" /> - <empty-state - v-if="shouldRenderEmptyState" - :help-page-path="helpPagePath" - :empty-state-svg-path="emptyStateSvgPath" - /> - - <error-state - v-if="shouldRenderErrorState" - :error-state-svg-path="errorStateSvgPath" + <svg-blank-state + v-else-if="shouldRenderErrorState" + :svg-path="errorStateSvgPath" + :message="s__(`Pipelines|There was an error fetching the pipelines. + Try again in a few moments or contact your support team.`)" /> <div class="table-holder" - v-if="shouldRenderTable" + v-else-if="shouldRenderTable" > <pipelines-table-component :pipelines="state.pipelines" diff --git a/app/assets/javascripts/commons/vue.js b/app/assets/javascripts/commons/vue.js index 8b62d78c043..798623b94fb 100644 --- a/app/assets/javascripts/commons/vue.js +++ b/app/assets/javascripts/commons/vue.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import '../vue_shared/vue_resource_interceptor'; if (process.env.NODE_ENV !== 'production') { Vue.config.productionTip = false; diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index b8f0566f48c..0578f43d5af 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -152,14 +152,14 @@ export default { showLeaveGroupModal(group, parentGroup) { this.targetGroup = group; this.targetParentGroup = parentGroup; - this.updateModal = true; + this.showModal = true; this.groupLeaveConfirmationMessage = s__(`GroupsTree|Are you sure you want to leave the "${group.fullName}" group?`); }, hideLeaveGroupModal() { - this.updateModal = false; + this.showModal = false; }, leaveGroup() { - this.updateModal = false; + this.showModal = false; this.targetGroup.isBeingRemoved = true; this.service.leaveGroup(this.targetGroup.leavePath) .then(res => res.json()) @@ -208,9 +208,9 @@ export default { :page-info="pageInfo" /> <modal - v-show="showModal" - :primary-button-label="__('Leave')" + v-if="showModal" kind="warning" + :primary-button-label="__('Leave')" :title="__('Are you sure?')" :text="groupLeaveConfirmationMessage" @cancel="hideLeaveGroupModal" diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index 35094f8e73b..523bd2adb93 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -1,11 +1,14 @@ -import { __ } from './locale'; +import _ from 'underscore'; +import { __, sprintf } from './locale'; import axios from './lib/utils/axios_utils'; import flash from './flash'; +import { convertPermissionToBoolean } from './lib/utils/common_utils'; class ImporterStatus { - constructor(jobsUrl, importUrl) { + constructor({ jobsUrl, importUrl, ciCdOnly }) { this.jobsUrl = jobsUrl; this.importUrl = importUrl; + this.ciCdOnly = ciCdOnly; this.initStatusPage(); this.setAutoUpdate(); } @@ -45,6 +48,7 @@ class ImporterStatus { repo_id: id, target_namespace: targetNamespace, new_name: newName, + ci_cd_only: this.ciCdOnly, }) .then(({ data }) => { const job = $(`tr#repo_${id}`); @@ -54,7 +58,13 @@ class ImporterStatus { $('table.import-jobs tbody').prepend(job); job.addClass('active'); - job.find('.import-actions').html('<i class="fa fa-spinner fa-spin" aria-label="importing"></i> started'); + const connectingVerb = this.ciCdOnly ? __('connecting') : __('importing'); + job.find('.import-actions').html(sprintf( + _.escape(__('%{loadingIcon} Started')), { + loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${_.escape(connectingVerb)}"></i>`, + }, + false, + )); }) .catch(() => flash(__('An error occurred while importing project'))); } @@ -71,13 +81,16 @@ class ImporterStatus { switch (job.import_status) { case 'finished': jobItem.removeClass('active').addClass('success'); - statusField.html('<span><i class="fa fa-check"></i> done</span>'); + statusField.html(`<span><i class="fa fa-check"></i> ${__('Done')}</span>`); break; case 'scheduled': - statusField.html(`${spinner} scheduled`); + statusField.html(`${spinner} ${__('Scheduled')}`); break; case 'started': - statusField.html(`${spinner} started`); + statusField.html(`${spinner} ${__('Started')}`); + break; + case 'failed': + statusField.html(__('Failed')); break; default: statusField.html(job.import_status); @@ -98,7 +111,11 @@ function initImporterStatus() { if (importerStatus) { const data = importerStatus.dataset; - return new ImporterStatus(data.jobsImportPath, data.importPath); + return new ImporterStatus({ + jobsUrl: data.jobsImportPath, + importUrl: data.importPath, + ciCdOnly: convertPermissionToBoolean(data.ciCdOnly), + }); } } diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 031badc7026..8ca94ef3e2a 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -7,34 +7,82 @@ import EmptyState from './empty_state.vue'; import MonitoringStore from '../stores/monitoring_store'; import eventHub from '../event_hub'; - import { convertPermissionToBoolean } from '../../lib/utils/common_utils'; export default { - components: { Graph, GraphGroup, EmptyState, }, - data() { - const metricsData = document.querySelector('#prometheus-graphs').dataset; - const store = new MonitoringStore(); + props: { + hasMetrics: { + type: Boolean, + required: false, + default: true, + }, + showLegend: { + type: Boolean, + required: false, + default: true, + }, + showPanels: { + type: Boolean, + required: false, + default: true, + }, + forceSmallGraph: { + type: Boolean, + required: false, + default: false, + }, + documentationPath: { + type: String, + required: true, + }, + settingsPath: { + type: String, + required: true, + }, + clustersPath: { + type: String, + required: true, + }, + tagsPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + metricsEndpoint: { + type: String, + required: true, + }, + deploymentEndpoint: { + type: String, + required: false, + default: null, + }, + emptyGettingStartedSvgPath: { + type: String, + required: true, + }, + emptyLoadingSvgPath: { + type: String, + required: true, + }, + emptyUnableToConnectSvgPath: { + type: String, + required: true, + }, + }, + data() { return { - store, + store: new MonitoringStore(), state: 'gettingStarted', - hasMetrics: convertPermissionToBoolean(metricsData.hasMetrics), - documentationPath: metricsData.documentationPath, - settingsPath: metricsData.settingsPath, - clustersPath: metricsData.clustersPath, - tagsPath: metricsData.tagsPath, - projectPath: metricsData.projectPath, - metricsEndpoint: metricsData.additionalMetrics, - deploymentEndpoint: metricsData.deploymentEndpoint, - emptyGettingStartedSvgPath: metricsData.emptyGettingStartedSvgPath, - emptyLoadingSvgPath: metricsData.emptyLoadingSvgPath, - emptyUnableToConnectSvgPath: metricsData.emptyUnableToConnectSvgPath, showEmptyState: true, updateAspectRatio: false, updatedAspectRatios: 0, @@ -67,6 +115,7 @@ window.addEventListener('resize', this.resizeThrottled, false); } }, + methods: { getGraphsData() { this.state = 'loading'; @@ -115,6 +164,7 @@ v-for="(groupData, index) in store.groups" :key="index" :name="groupData.group" + :show-panels="showPanels" > <graph v-for="(graphData, index) in groupData.metrics" @@ -125,6 +175,8 @@ :deployment-data="store.deploymentData" :project-path="projectPath" :tags-path="tagsPath" + :show-legend="showLegend" + :small-graph="forceSmallGraph" /> </graph-group> </div> diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index ea5c24efaf9..9e67a6f2146 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -52,6 +52,16 @@ type: String, required: true, }, + showLegend: { + type: Boolean, + required: false, + default: true, + }, + smallGraph: { + type: Boolean, + required: false, + default: false, + }, }, data() { @@ -130,7 +140,7 @@ const breakpointSize = bp.getBreakpointSize(); const query = this.graphData.queries[0]; this.margin = measurements.large.margin; - if (breakpointSize === 'xs' || breakpointSize === 'sm') { + if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') { this.graphHeight = 300; this.margin = measurements.small.margin; this.measurements = measurements.small; @@ -182,7 +192,9 @@ this.graphHeightOffset, ); - if (this.timeSeries.length > 3) { + if (!this.showLegend) { + this.baseGraphHeight -= 50; + } else if (this.timeSeries.length > 3) { this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20; } @@ -255,6 +267,7 @@ :time-series="timeSeries" :unit-of-display="unitOfDisplay" :current-data-index="currentDataIndex" + :show-legend-group="showLegend" /> <svg class="graph-data" diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue index c6e8d726ffc..3149397b61f 100644 --- a/app/assets/javascripts/monitoring/components/graph/legend.vue +++ b/app/assets/javascripts/monitoring/components/graph/legend.vue @@ -39,6 +39,11 @@ type: Number, required: true, }, + showLegendGroup: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -57,8 +62,9 @@ }, rectTransform() { - const yCoordinate = ((this.graphHeight - this.margin.top) / 2) - + (this.yLabelWidth / 2) + 10 || 0; + const yCoordinate = (((this.graphHeight - this.margin.top) + + this.measurements.axisLabelLineOffset) / 2) + + (this.yLabelWidth / 2) || 0; return `translate(0, ${yCoordinate}) rotate(-90)`; }, @@ -166,39 +172,41 @@ > Time </text> - <g - class="legend-group" - v-for="(series, index) in timeSeries" - :key="index" - :transform="translateLegendGroup(index)" - > - <line - :stroke="series.lineColor" - :stroke-width="measurements.legends.height" - :stroke-dasharray="strokeDashArray(series.lineStyle)" - :x1="measurements.legends.offsetX" - :x2="measurements.legends.offsetX + measurements.legends.width" - :y1="graphHeight - measurements.legends.offsetY" - :y2="graphHeight - measurements.legends.offsetY" - /> - <text - v-if="timeSeries.length > 1" - class="legend-metric-title" - ref="legendTitleSvg" - x="38" - :y="graphHeight - 30" - > - {{ createSeriesString(index, series) }} - </text> - <text - v-else - class="legend-metric-title" - ref="legendTitleSvg" - x="38" - :y="graphHeight - 30" + <template v-if="showLegendGroup"> + <g + class="legend-group" + v-for="(series, index) in timeSeries" + :key="index" + :transform="translateLegendGroup(index)" > - {{ legendTitle }} {{ formatMetricUsage(series) }} - </text> - </g> + <line + :stroke="series.lineColor" + :stroke-width="measurements.legends.height" + :stroke-dasharray="strokeDashArray(series.lineStyle)" + :x1="measurements.legends.offsetX" + :x2="measurements.legends.offsetX + measurements.legends.width" + :y1="graphHeight - measurements.legends.offsetY" + :y2="graphHeight - measurements.legends.offsetY" + /> + <text + v-if="timeSeries.length > 1" + class="legend-metric-title" + ref="legendTitleSvg" + x="38" + :y="graphHeight - 30" + > + {{ createSeriesString(index, series) }} + </text> + <text + v-else + class="legend-metric-title" + ref="legendTitleSvg" + x="38" + :y="graphHeight - 30" + > + {{ legendTitle }} {{ formatMetricUsage(series) }} + </text> + </g> + </template> </g> </template> diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index 079351a69af..f71cf614552 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -5,12 +5,20 @@ type: String, required: true, }, + showPanels: { + type: Boolean, + required: false, + default: true, + }, }, }; </script> <template> - <div class="panel panel-default prometheus-panel"> + <div + v-if="showPanels" + class="panel panel-default prometheus-panel" + > <div class="panel-heading"> <h4>{{ name }}</h4> </div> @@ -18,4 +26,10 @@ <slot></slot> </div> </div> + <div + v-else + class="prometheus-graph-group" + > + <slot></slot> + </div> </template> diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index c3b0ef7e9ca..41270e015d4 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,7 +1,22 @@ import Vue from 'vue'; +import { convertPermissionToBoolean } from '~/lib/utils/common_utils'; import Dashboard from './components/dashboard.vue'; -export default () => new Vue({ - el: '#prometheus-graphs', - render: createElement => createElement(Dashboard), -}); +export default () => { + const el = document.getElementById('prometheus-graphs'); + + if (el && el.dataset) { + // eslint-disable-next-line no-new + new Vue({ + el, + render(createElement) { + return createElement(Dashboard, { + props: { + ...el.dataset, + hasMetrics: convertPermissionToBoolean(el.dataset.hasMetrics), + }, + }); + }, + }); + } +}; diff --git a/app/assets/javascripts/monitoring/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js index e230a06cd8c..6fcca36d2fa 100644 --- a/app/assets/javascripts/monitoring/services/monitoring_service.js +++ b/app/assets/javascripts/monitoring/services/monitoring_service.js @@ -40,6 +40,9 @@ export default class MonitoringService { } getDeploymentData() { + if (!this.deploymentEndpoint) { + return Promise.resolve([]); + } return backOffRequest(() => axios.get(this.deploymentEndpoint)) .then(resp => resp.data) .then((response) => { diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js index 4ce3dad440c..b5b8e3c255d 100644 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js @@ -76,7 +76,7 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom metricTag = seriesCustomizationData.value || timeSeriesMetricLabel; [lineColor, areaColor] = pickColor(seriesCustomizationData.color); } else { - metricTag = timeSeriesMetricLabel || `series ${timeSeriesNumber + 1}`; + metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`; [lineColor, areaColor] = pickColor(); } diff --git a/app/assets/javascripts/notes/components/diff_file_header.vue b/app/assets/javascripts/notes/components/diff_file_header.vue index fe5baa3537f..3bcde17f07c 100644 --- a/app/assets/javascripts/notes/components/diff_file_header.vue +++ b/app/assets/javascripts/notes/components/diff_file_header.vue @@ -35,6 +35,7 @@ <clipboard-button title="Copy file path to clipboard" :text="diffFile.submoduleLink" + css-class="btn-default btn-transparent btn-clipboard" /> </span> </div> @@ -79,6 +80,7 @@ <clipboard-button title="Copy file path to clipboard" :text="diffFile.filePath" + css-class="btn-default btn-transparent btn-clipboard" /> <small diff --git a/app/assets/javascripts/pages/groups/boards/index.js b/app/assets/javascripts/pages/groups/boards/index.js new file mode 100644 index 00000000000..5cfe8723204 --- /dev/null +++ b/app/assets/javascripts/pages/groups/boards/index.js @@ -0,0 +1,9 @@ +import UsersSelect from '~/users_select'; +import ShortcutsNavigation from '~/shortcuts_navigation'; +import initBoards from '~/boards'; + +document.addEventListener('DOMContentLoaded', () => { + new UsersSelect(); // eslint-disable-line no-new + new ShortcutsNavigation(); // eslint-disable-line no-new + initBoards(); +}); diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue new file mode 100644 index 00000000000..22248418c41 --- /dev/null +++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue @@ -0,0 +1,64 @@ +<script> + import axios from '~/lib/utils/axios_utils'; + import createFlash from '~/flash'; + import GlModal from '~/vue_shared/components/gl_modal.vue'; + import { s__, sprintf } from '~/locale'; + import { visitUrl } from '~/lib/utils/url_utility'; + import eventHub from '../event_hub'; + + export default { + components: { + GlModal, + }, + props: { + milestoneTitle: { + type: String, + required: true, + }, + url: { + type: String, + required: true, + }, + }, + computed: { + title() { + return sprintf(s__('Milestones|Promote %{milestoneTitle} to group milestone?'), { milestoneTitle: this.milestoneTitle }); + }, + text() { + return s__(`Milestones|Promoting this milestone will make it available for all projects inside the group. + Existing project milestones with the same title will be merged. + This action cannot be reversed.`); + }, + }, + methods: { + onSubmit() { + eventHub.$emit('promoteMilestoneModal.requestStarted', this.url); + return axios.post(this.url, { params: { format: 'json' } }) + .then((response) => { + eventHub.$emit('promoteMilestoneModal.requestFinished', { milestoneUrl: this.url, successful: true }); + visitUrl(response.data.url); + }) + .catch((error) => { + eventHub.$emit('promoteMilestoneModal.requestFinished', { milestoneUrl: this.url, successful: false }); + createFlash(error); + }); + }, + }, + }; +</script> +<template> + <gl-modal + id="promote-milestone-modal" + footer-primary-button-variant="warning" + :footer-primary-button-text="s__('Milestones|Promote Milestone')" + @submit="onSubmit" + > + <template + slot="title" + > + {{ title }} + </template> + {{ text }} + </gl-modal> +</template> + diff --git a/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js new file mode 100644 index 00000000000..d51b5c221e3 --- /dev/null +++ b/app/assets/javascripts/pages/milestones/shared/delete_milestone_modal_init.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import deleteMilestoneModal from './components/delete_milestone_modal.vue'; +import eventHub from './event_hub'; + +export default () => { + Vue.use(Translate); + + const onRequestFinished = ({ milestoneUrl, successful }) => { + const button = document.querySelector(`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`); + + if (!successful) { + button.removeAttribute('disabled'); + } + + button.querySelector('.js-loading-icon').classList.add('hidden'); + }; + + const onRequestStarted = (milestoneUrl) => { + const button = document.querySelector(`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`); + button.setAttribute('disabled', ''); + button.querySelector('.js-loading-icon').classList.remove('hidden'); + eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished); + }; + + const onDeleteButtonClick = (event) => { + const button = event.currentTarget; + const modalProps = { + milestoneId: parseInt(button.dataset.milestoneId, 10), + milestoneTitle: button.dataset.milestoneTitle, + milestoneUrl: button.dataset.milestoneUrl, + issueCount: parseInt(button.dataset.milestoneIssueCount, 10), + mergeRequestCount: parseInt(button.dataset.milestoneMergeRequestCount, 10), + }; + eventHub.$once('deleteMilestoneModal.requestStarted', onRequestStarted); + eventHub.$emit('deleteMilestoneModal.props', modalProps); + }; + + const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button'); + deleteMilestoneButtons.forEach((button) => { + button.addEventListener('click', onDeleteButtonClick); + }); + + eventHub.$once('deleteMilestoneModal.mounted', () => { + deleteMilestoneButtons.forEach((button) => { + button.removeAttribute('disabled'); + }); + }); + + return new Vue({ + el: '#delete-milestone-modal', + components: { + deleteMilestoneModal, + }, + data() { + return { + modalProps: { + milestoneId: -1, + milestoneTitle: '', + milestoneUrl: '', + issueCount: -1, + mergeRequestCount: -1, + }, + }; + }, + mounted() { + eventHub.$on('deleteMilestoneModal.props', this.setModalProps); + eventHub.$emit('deleteMilestoneModal.mounted'); + }, + beforeDestroy() { + eventHub.$off('deleteMilestoneModal.props', this.setModalProps); + }, + methods: { + setModalProps(modalProps) { + this.modalProps = modalProps; + }, + }, + render(createElement) { + return createElement(deleteMilestoneModal, { + props: this.modalProps, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pages/milestones/shared/index.js b/app/assets/javascripts/pages/milestones/shared/index.js index 327e2cf569c..dabfe32848b 100644 --- a/app/assets/javascripts/pages/milestones/shared/index.js +++ b/app/assets/javascripts/pages/milestones/shared/index.js @@ -1,88 +1,7 @@ -import Vue from 'vue'; - -import Translate from '~/vue_shared/translate'; - -import deleteMilestoneModal from './components/delete_milestone_modal.vue'; -import eventHub from './event_hub'; +import initDeleteMilestoneModal from './delete_milestone_modal_init'; +import initPromoteMilestoneModal from './promote_milestone_modal_init'; export default () => { - Vue.use(Translate); - - const onRequestFinished = ({ milestoneUrl, successful }) => { - const button = document.querySelector(`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`); - - if (!successful) { - button.removeAttribute('disabled'); - } - - button.querySelector('.js-loading-icon').classList.add('hidden'); - }; - - const onRequestStarted = (milestoneUrl) => { - const button = document.querySelector(`.js-delete-milestone-button[data-milestone-url="${milestoneUrl}"]`); - button.setAttribute('disabled', ''); - button.querySelector('.js-loading-icon').classList.remove('hidden'); - eventHub.$once('deleteMilestoneModal.requestFinished', onRequestFinished); - }; - - const onDeleteButtonClick = (event) => { - const button = event.currentTarget; - const modalProps = { - milestoneId: parseInt(button.dataset.milestoneId, 10), - milestoneTitle: button.dataset.milestoneTitle, - milestoneUrl: button.dataset.milestoneUrl, - issueCount: parseInt(button.dataset.milestoneIssueCount, 10), - mergeRequestCount: parseInt(button.dataset.milestoneMergeRequestCount, 10), - }; - eventHub.$once('deleteMilestoneModal.requestStarted', onRequestStarted); - eventHub.$emit('deleteMilestoneModal.props', modalProps); - }; - - const deleteMilestoneButtons = document.querySelectorAll('.js-delete-milestone-button'); - for (let i = 0; i < deleteMilestoneButtons.length; i += 1) { - const button = deleteMilestoneButtons[i]; - button.addEventListener('click', onDeleteButtonClick); - } - - eventHub.$once('deleteMilestoneModal.mounted', () => { - for (let i = 0; i < deleteMilestoneButtons.length; i += 1) { - const button = deleteMilestoneButtons[i]; - button.removeAttribute('disabled'); - } - }); - - return new Vue({ - el: '#delete-milestone-modal', - components: { - deleteMilestoneModal, - }, - data() { - return { - modalProps: { - milestoneId: -1, - milestoneTitle: '', - milestoneUrl: '', - issueCount: -1, - mergeRequestCount: -1, - }, - }; - }, - mounted() { - eventHub.$on('deleteMilestoneModal.props', this.setModalProps); - eventHub.$emit('deleteMilestoneModal.mounted'); - }, - beforeDestroy() { - eventHub.$off('deleteMilestoneModal.props', this.setModalProps); - }, - methods: { - setModalProps(modalProps) { - this.modalProps = modalProps; - }, - }, - render(createElement) { - return createElement(deleteMilestoneModal, { - props: this.modalProps, - }); - }, - }); + initDeleteMilestoneModal(); + initPromoteMilestoneModal(); }; diff --git a/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js new file mode 100644 index 00000000000..d00f81c9094 --- /dev/null +++ b/app/assets/javascripts/pages/milestones/shared/promote_milestone_modal_init.js @@ -0,0 +1,82 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import PromoteMilestoneModal from './components/promote_milestone_modal.vue'; +import eventHub from './event_hub'; + +Vue.use(Translate); + +export default () => { + const onRequestFinished = ({ milestoneUrl, successful }) => { + const button = document.querySelector(`.js-promote-project-milestone-button[data-url="${milestoneUrl}"]`); + + if (!successful) { + button.removeAttribute('disabled'); + } + }; + + const onRequestStarted = (milestoneUrl) => { + const button = document.querySelector(`.js-promote-project-milestone-button[data-url="${milestoneUrl}"]`); + button.setAttribute('disabled', ''); + eventHub.$once('promoteMilestoneModal.requestFinished', onRequestFinished); + }; + + const onDeleteButtonClick = (event) => { + const button = event.currentTarget; + const modalProps = { + milestoneTitle: button.dataset.milestoneTitle, + url: button.dataset.url, + }; + eventHub.$once('promoteMilestoneModal.requestStarted', onRequestStarted); + eventHub.$emit('promoteMilestoneModal.props', modalProps); + }; + + const promoteMilestoneButtons = document.querySelectorAll('.js-promote-project-milestone-button'); + promoteMilestoneButtons.forEach((button) => { + button.addEventListener('click', onDeleteButtonClick); + }); + + eventHub.$once('promoteMilestoneModal.mounted', () => { + promoteMilestoneButtons.forEach((button) => { + button.removeAttribute('disabled'); + }); + }); + + const promoteMilestoneModal = document.getElementById('promote-milestone-modal'); + let promoteMilestoneComponent; + + if (promoteMilestoneModal) { + promoteMilestoneComponent = new Vue({ + el: promoteMilestoneModal, + components: { + PromoteMilestoneModal, + }, + data() { + return { + modalProps: { + milestoneTitle: '', + url: '', + }, + }; + }, + mounted() { + eventHub.$on('promoteMilestoneModal.props', this.setModalProps); + eventHub.$emit('promoteMilestoneModal.mounted'); + }, + beforeDestroy() { + eventHub.$off('promoteMilestoneModal.props', this.setModalProps); + }, + methods: { + setModalProps(modalProps) { + this.modalProps = modalProps; + }, + }, + render(createElement) { + return createElement('promote-milestone-modal', { + props: this.modalProps, + }); + }, + }); + } + + return promoteMilestoneComponent; +}; diff --git a/app/assets/javascripts/pages/projects/environments/index.js b/app/assets/javascripts/pages/projects/environments/index/index.js index ace8af00ece..ace8af00ece 100644 --- a/app/assets/javascripts/pages/projects/environments/index.js +++ b/app/assets/javascripts/pages/projects/environments/index/index.js diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue new file mode 100644 index 00000000000..54695dfeb99 --- /dev/null +++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue @@ -0,0 +1,79 @@ +<script> + import axios from '~/lib/utils/axios_utils'; + import createFlash from '~/flash'; + import GlModal from '~/vue_shared/components/gl_modal.vue'; + import { s__, sprintf } from '~/locale'; + import { visitUrl } from '~/lib/utils/url_utility'; + import eventHub from '../event_hub'; + + export default { + components: { + GlModal, + }, + props: { + url: { + type: String, + required: true, + }, + labelTitle: { + type: String, + required: true, + }, + labelColor: { + type: String, + required: true, + }, + labelTextColor: { + type: String, + required: true, + }, + }, + computed: { + text() { + return s__(`Milestones|Promoting this label will make it available for all projects inside the group. + Existing project labels with the same title will be merged. This action cannot be reversed.`); + }, + title() { + const label = `<span + class="label color-label" + style="background-color: ${this.labelColor}; color: ${this.labelTextColor};" + >${this.labelTitle}</span>`; + + return sprintf(s__('Labels|Promote label %{labelTitle} to Group Label?'), { + labelTitle: label, + }, false); + }, + }, + methods: { + onSubmit() { + eventHub.$emit('promoteLabelModal.requestStarted', this.url); + return axios.post(this.url, { params: { format: 'json' } }) + .then((response) => { + eventHub.$emit('promoteLabelModal.requestFinished', { labelUrl: this.url, successful: true }); + visitUrl(response.data.url); + }) + .catch((error) => { + eventHub.$emit('promoteLabelModal.requestFinished', { labelUrl: this.url, successful: false }); + createFlash(error); + }); + }, + }, + }; +</script> +<template> + <gl-modal + id="promote-label-modal" + footer-primary-button-variant="warning" + :footer-primary-button-text="s__('Labels|Promote Label')" + @submit="onSubmit" + > + <div + slot="title" + v-html="title" + > + {{ title }} + </div> + + {{ text }} + </gl-modal> +</template> diff --git a/app/assets/javascripts/pages/projects/labels/event_hub.js b/app/assets/javascripts/pages/projects/labels/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/pages/projects/labels/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/pages/projects/labels/index/index.js b/app/assets/javascripts/pages/projects/labels/index/index.js index 6e45de2a724..2abcbfab1ed 100644 --- a/app/assets/javascripts/pages/projects/labels/index/index.js +++ b/app/assets/javascripts/pages/projects/labels/index/index.js @@ -1,3 +1,91 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; import initLabels from '~/init_labels'; +import eventHub from '../event_hub'; +import PromoteLabelModal from '../components/promote_label_modal.vue'; -document.addEventListener('DOMContentLoaded', initLabels); +Vue.use(Translate); + +const initLabelIndex = () => { + initLabels(); + + const onRequestFinished = ({ labelUrl, successful }) => { + const button = document.querySelector(`.js-promote-project-label-button[data-url="${labelUrl}"]`); + + if (!successful) { + button.removeAttribute('disabled'); + } + }; + + const onRequestStarted = (labelUrl) => { + const button = document.querySelector(`.js-promote-project-label-button[data-url="${labelUrl}"]`); + button.setAttribute('disabled', ''); + eventHub.$once('promoteLabelModal.requestFinished', onRequestFinished); + }; + + const onDeleteButtonClick = (event) => { + const button = event.currentTarget; + const modalProps = { + labelTitle: button.dataset.labelTitle, + labelColor: button.dataset.labelColor, + labelTextColor: button.dataset.labelTextColor, + url: button.dataset.url, + }; + eventHub.$once('promoteLabelModal.requestStarted', onRequestStarted); + eventHub.$emit('promoteLabelModal.props', modalProps); + }; + + const promoteLabelButtons = document.querySelectorAll('.js-promote-project-label-button'); + promoteLabelButtons.forEach((button) => { + button.addEventListener('click', onDeleteButtonClick); + }); + + eventHub.$once('promoteLabelModal.mounted', () => { + promoteLabelButtons.forEach((button) => { + button.removeAttribute('disabled'); + }); + }); + + const promoteLabelModal = document.getElementById('promote-label-modal'); + let promoteLabelModalComponent; + + if (promoteLabelModal) { + promoteLabelModalComponent = new Vue({ + el: promoteLabelModal, + components: { + PromoteLabelModal, + }, + data() { + return { + modalProps: { + labelTitle: '', + labelColor: '', + labelTextColor: '', + url: '', + }, + }; + }, + mounted() { + eventHub.$on('promoteLabelModal.props', this.setModalProps); + eventHub.$emit('promoteLabelModal.mounted'); + }, + beforeDestroy() { + eventHub.$off('promoteLabelModal.props', this.setModalProps); + }, + methods: { + setModalProps(modalProps) { + this.modalProps = modalProps; + }, + }, + render(createElement) { + return createElement('promote-label-modal', { + props: this.modalProps, + }); + }, + }); + } + + return promoteLabelModalComponent; +}; + +document.addEventListener('DOMContentLoaded', initLabelIndex); diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js index 25dfa99ad9c..a84e2790680 100644 --- a/app/assets/javascripts/pages/projects/pipelines/index/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import PipelinesStore from '../../../../pipelines/stores/pipelines_store'; import pipelinesComponent from '../../../../pipelines/components/pipelines.vue'; import Translate from '../../../../vue_shared/translate'; +import { convertPermissionToBoolean } from '../../../../lib/utils/common_utils'; Vue.use(Translate); @@ -11,16 +12,28 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ pipelinesComponent, }, data() { - const store = new PipelinesStore(); - return { - store, + store: new PipelinesStore(), }; }, + created() { + this.dataset = document.querySelector(this.$options.el).dataset; + }, render(createElement) { return createElement('pipelines-component', { props: { store: this.store, + endpoint: this.dataset.endpoint, + helpPagePath: this.dataset.helpPagePath, + emptyStateSvgPath: this.dataset.emptyStateSvgPath, + errorStateSvgPath: this.dataset.errorStateSvgPath, + noPipelinesSvgPath: this.dataset.noPipelinesSvgPath, + autoDevopsPath: this.dataset.helpAutoDevopsPath, + newPipelinePath: this.dataset.newPipelinePath, + canCreatePipeline: convertPermissionToBoolean(this.dataset.canCreatePipeline), + hasGitlabCi: convertPermissionToBoolean(this.dataset.hasGitlabCi), + ciLintPath: this.dataset.ciLintPath, + resetCachePath: this.dataset.resetCachePath, }, }); }, diff --git a/app/assets/javascripts/pipelines/components/blank_state.vue b/app/assets/javascripts/pipelines/components/blank_state.vue new file mode 100644 index 00000000000..8d3d6223d7b --- /dev/null +++ b/app/assets/javascripts/pipelines/components/blank_state.vue @@ -0,0 +1,32 @@ +<script> + export default { + name: 'PipelinesSvgState', + props: { + svgPath: { + type: String, + required: true, + }, + + message: { + type: String, + required: true, + }, + }, + }; +</script> + +<template> + <div class="row empty-state"> + <div class="col-xs-12"> + <div class="svg-content"> + <img :src="svgPath" /> + </div> + </div> + + <div class="col-xs-12 text-center"> + <div class="text-content"> + <h4>{{ message }}</h4> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/empty_state.vue b/app/assets/javascripts/pipelines/components/empty_state.vue index dfaa2574091..10ac8c08bed 100644 --- a/app/assets/javascripts/pipelines/components/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/empty_state.vue @@ -1,5 +1,6 @@ <script> export default { + name: 'PipelinesEmptyState', props: { helpPagePath: { type: String, @@ -9,6 +10,10 @@ type: String, required: true, }, + canSetCi: { + type: Boolean, + required: true, + }, }, }; </script> @@ -22,22 +27,36 @@ <div class="col-xs-12"> <div class="text-content"> - <h4 class="text-center"> - {{ s__("Pipelines|Build with confidence") }} - </h4> - <p> - {{ s__(`Pipelines|Continous Integration can help -catch bugs by running your tests automatically, -while Continuous Deployment can help you deliver code to your product environment.`) }} + + <template v-if="canSetCi"> + <h4 class="text-center"> + {{ s__('Pipelines|Build with confidence') }} + </h4> + + <p> + {{ s__(`Pipelines|Continous Integration can help + catch bugs by running your tests automatically, + while Continuous Deployment can help you deliver + code to your product environment.`) }} + </p> + + <div class="text-center"> + <a + :href="helpPagePath" + class="btn btn-primary js-get-started-pipelines" + > + {{ s__('Pipelines|Get started with Pipelines') }} + </a> + </div> + </template> + + <p + v-else + class="text-center" + > + {{ s__('Pipelines|This project is not currently set up to run pipelines.') }} </p> - <div class="text-center"> - <a - :href="helpPagePath" - class="btn btn-info" - > - {{ s__("Pipelines|Get started with Pipelines") }} - </a> - </div> + </div> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/error_state.vue b/app/assets/javascripts/pipelines/components/error_state.vue deleted file mode 100644 index 012853b201d..00000000000 --- a/app/assets/javascripts/pipelines/components/error_state.vue +++ /dev/null @@ -1,26 +0,0 @@ -<script> -export default { - props: { - errorStateSvgPath: { - type: String, - required: true, - }, - }, -}; -</script> - -<template> - <div class="row empty-state js-pipelines-error-state"> - <div class="col-xs-12"> - <div class="svg-content"> - <img :src="errorStateSvgPath"/> - </div> - </div> - - <div class="col-xs-12 text-center"> - <div class="text-content"> - <h4>The API failed to fetch the pipelines.</h4> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/nav_controls.vue index f31a91c3403..383ab51fe56 100644 --- a/app/assets/javascripts/pipelines/components/nav_controls.vue +++ b/app/assets/javascripts/pipelines/components/nav_controls.vue @@ -1,67 +1,52 @@ <script> -export default { - name: 'PipelineNavControls', - props: { - newPipelinePath: { - type: String, - required: true, + export default { + name: 'PipelineNavControls', + props: { + newPipelinePath: { + type: String, + required: false, + default: null, + }, + + resetCachePath: { + type: String, + required: false, + default: null, + }, + + ciLintPath: { + type: String, + required: false, + default: null, + }, }, - - hasCiEnabled: { - type: Boolean, - required: true, - }, - - helpPagePath: { - type: String, - required: true, - }, - - resetCachePath: { - type: String, - required: true, - }, - - ciLintPath: { - type: String, - required: true, - }, - - canCreatePipeline: { - type: Boolean, - required: true, - }, - }, -}; + }; </script> <template> <div class="nav-controls"> <a - v-if="canCreatePipeline" + v-if="newPipelinePath" :href="newPipelinePath" - class="btn btn-create"> - Run Pipeline - </a> - - <a - v-if="!hasCiEnabled" - :href="helpPagePath" - class="btn btn-info"> - Get started with Pipelines + class="btn btn-create js-run-pipeline" + > + {{ s__('Pipelines|Run Pipeline') }} </a> <a + v-if="resetCachePath" data-method="post" - rel="nofollow" :href="resetCachePath" - class="btn btn-default"> - Clear runner caches + class="btn btn-default js-clear-cache" + > + {{ s__('Pipelines|Clear Runner Caches') }} </a> <a + v-if="ciLintPath" :href="ciLintPath" - class="btn btn-default"> - CI Lint + class="btn btn-default js-ci-lint" + > + {{ s__('Pipelines|CI Lint') }} </a> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue index 90930d5ff44..6e5ee68eeb1 100644 --- a/app/assets/javascripts/pipelines/components/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines.vue @@ -1,12 +1,12 @@ <script> import _ from 'underscore'; + import { __, sprintf, s__ } from '../../locale'; import PipelinesService from '../services/pipelines_service'; import pipelinesMixin from '../mixins/pipelines'; - import tablePagination from '../../vue_shared/components/table_pagination.vue'; - import navigationTabs from '../../vue_shared/components/navigation_tabs.vue'; - import navigationControls from './nav_controls.vue'; + import TablePagination from '../../vue_shared/components/table_pagination.vue'; + import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue'; + import NavigationControls from './nav_controls.vue'; import { - convertPermissionToBoolean, getParameterByName, parseQueryStringIntoObject, } from '../../lib/utils/common_utils'; @@ -14,9 +14,9 @@ export default { components: { - tablePagination, - navigationTabs, - navigationControls, + TablePagination, + NavigationTabs, + NavigationControls, }, mixins: [ pipelinesMixin, @@ -36,111 +36,186 @@ required: false, default: 'root', }, + endpoint: { + type: String, + required: true, + }, + helpPagePath: { + type: String, + required: true, + }, + emptyStateSvgPath: { + type: String, + required: true, + }, + errorStateSvgPath: { + type: String, + required: true, + }, + noPipelinesSvgPath: { + type: String, + required: true, + }, + autoDevopsPath: { + type: String, + required: true, + }, + hasGitlabCi: { + type: Boolean, + required: true, + }, + canCreatePipeline: { + type: Boolean, + required: true, + }, + ciLintPath: { + type: String, + required: false, + default: null, + }, + resetCachePath: { + type: String, + required: false, + default: null, + }, + newPipelinePath: { + type: String, + required: false, + default: null, + }, }, data() { - const pipelinesData = document.querySelector('#pipelines-list-vue').dataset; - return { - endpoint: pipelinesData.endpoint, - helpPagePath: pipelinesData.helpPagePath, - emptyStateSvgPath: pipelinesData.emptyStateSvgPath, - errorStateSvgPath: pipelinesData.errorStateSvgPath, - autoDevopsPath: pipelinesData.helpAutoDevopsPath, - newPipelinePath: pipelinesData.newPipelinePath, - canCreatePipeline: pipelinesData.canCreatePipeline, - hasCi: pipelinesData.hasCi, - ciLintPath: pipelinesData.ciLintPath, - resetCachePath: pipelinesData.resetCachePath, + // Start with loading state to avoid a glitch when the empty state will be rendered + isLoading: true, state: this.store.state, scope: getParameterByName('scope') || 'all', page: getParameterByName('page') || '1', requestData: {}, }; }, - computed: { - canCreatePipelineParsed() { - return convertPermissionToBoolean(this.canCreatePipeline); - }, + stateMap: { + // with tabs + loading: 'loading', + tableList: 'tableList', + error: 'error', + emptyTab: 'emptyTab', + // without tabs + emptyState: 'emptyState', + }, + scopes: { + all: 'all', + pending: 'pending', + running: 'running', + finished: 'finished', + branches: 'branches', + tags: 'tags', + }, + computed: { /** - * The empty state should only be rendered when the request is made to fetch all pipelines - * and none is returned. - * - * @return {Boolean} - */ - shouldRenderEmptyState() { - return !this.isLoading && - !this.hasError && - this.hasMadeRequest && - !this.state.pipelines.length && - (this.scope === 'all' || this.scope === null); + * `hasGitlabCi` handles both internal and external CI. + * The order on which the checks are made in this method is + * important to guarantee we handle all the corner cases. + */ + stateToRender() { + const { stateMap } = this.$options; + + if (this.isLoading) { + return stateMap.loading; + } + + if (this.hasError) { + return stateMap.error; + } + + if (this.state.pipelines.length) { + return stateMap.tableList; + } + + if ((this.scope !== 'all' && this.scope !== null) || this.hasGitlabCi) { + return stateMap.emptyTab; + } + + return stateMap.emptyState; }, /** - * When a specific scope does not have pipelines we render a message. - * - * @return {Boolean} + * Tabs are rendered in all states except empty state. + * They are not rendered before the first request to avoid a flicker on first load. */ - shouldRenderNoPipelinesMessage() { - return !this.isLoading && - !this.hasError && - !this.state.pipelines.length && - this.scope !== 'all' && - this.scope !== null; + shouldRenderTabs() { + const { stateMap } = this.$options; + return this.hasMadeRequest && + [ + stateMap.loading, + stateMap.tableList, + stateMap.error, + stateMap.emptyTab, + ].includes(this.stateToRender); }, - shouldRenderTable() { - return !this.hasError && - !this.isLoading && this.state.pipelines.length; + shouldRenderButtons() { + return (this.newPipelinePath || + this.resetCachePath || + this.ciLintPath) && this.shouldRenderTabs; }, - /** - * Pagination should only be rendered when there is more than one page. - * - * @return {Boolean} - */ + shouldRenderPagination() { return !this.isLoading && this.state.pipelines.length && this.state.pageInfo.total > this.state.pageInfo.perPage; }, - hasCiEnabled() { - return this.hasCi !== undefined; + + emptyTabMessage() { + const { scopes } = this.$options; + const possibleScopes = [scopes.pending, scopes.running, scopes.finished]; + + if (possibleScopes.includes(this.scope)) { + return sprintf(s__('Pipelines|There are currently no %{scope} pipelines.'), { + scope: this.scope, + }); + } + + return s__('Pipelines|There are currently no pipelines.'); }, tabs() { const { count } = this.state; + const { scopes } = this.$options; + return [ { - name: 'All', - scope: 'all', + name: __('All'), + scope: scopes.all, count: count.all, isActive: this.scope === 'all', }, { - name: 'Pending', - scope: 'pending', + name: __('Pending'), + scope: scopes.pending, count: count.pending, isActive: this.scope === 'pending', }, { - name: 'Running', - scope: 'running', + name: __('Running'), + scope: scopes.running, count: count.running, isActive: this.scope === 'running', }, { - name: 'Finished', - scope: 'finished', + name: __('Finished'), + scope: scopes.finished, count: count.finished, isActive: this.scope === 'finished', }, { - name: 'Branches', - scope: 'branches', + name: __('Branches'), + scope: scopes.branches, isActive: this.scope === 'branches', }, { - name: 'Tags', - scope: 'tags', + name: __('Tags'), + scope: scopes.tags, isActive: this.scope === 'tags', }, ]; @@ -187,7 +262,7 @@ this.errorCallback(); // restart polling - this.poll.restart(); + this.poll.restart({ data: this.requestData }); }); }, }, @@ -197,69 +272,70 @@ <div class="pipelines-container"> <div class="top-area scrolling-tabs-container inner-page-scroll-tabs" - v-if="!shouldRenderEmptyState" + v-if="shouldRenderTabs || shouldRenderButtons" > <div class="fade-left"> <i class="fa fa-angle-left" - aria-hidden="true"> + aria-hidden="true" + > </i> </div> <div class="fade-right"> <i class="fa fa-angle-right" - aria-hidden="true"> + aria-hidden="true" + > </i> </div> <navigation-tabs + v-if="shouldRenderTabs" :tabs="tabs" @onChangeTab="onChangeTab" scope="pipelines" /> <navigation-controls + v-if="shouldRenderButtons" :new-pipeline-path="newPipelinePath" - :has-ci-enabled="hasCiEnabled" - :help-page-path="helpPagePath" :reset-cache-path="resetCachePath" :ci-lint-path="ciLintPath" - :can-create-pipeline="canCreatePipelineParsed " /> </div> <div class="content-list pipelines"> <loading-icon - label="Loading Pipelines" + v-if="stateToRender === $options.stateMap.loading" + :label="s__('Pipelines|Loading Pipelines')" size="3" - v-if="isLoading" class="prepend-top-20" /> <empty-state - v-if="shouldRenderEmptyState" + v-else-if="stateToRender === $options.stateMap.emptyState" :help-page-path="helpPagePath" :empty-state-svg-path="emptyStateSvgPath" + :can-set-ci="canCreatePipeline" /> - <error-state - v-if="shouldRenderErrorState" - :error-state-svg-path="errorStateSvgPath" + <svg-blank-state + v-else-if="stateToRender === $options.stateMap.error" + :svg-path="errorStateSvgPath" + :message="s__(`Pipelines|There was an error fetching the pipelines. + Try again in a few moments or contact your support team.`)" /> - <div - class="blank-state-row" - v-if="shouldRenderNoPipelinesMessage" - > - <div class="blank-state-center"> - <h2 class="blank-state-title js-blank-state-title">No pipelines to show.</h2> - </div> - </div> + <svg-blank-state + v-else-if="stateToRender === $options.stateMap.emptyTab" + :svg-path="noPipelinesSvgPath" + :message="emptyTabMessage" + /> <div class="table-holder" - v-if="shouldRenderTable" + v-else-if="stateToRender === $options.stateMap.tableList" > <pipelines-table-component diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 50bdf80c3e3..9fcc07abee5 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -1,23 +1,19 @@ import Visibility from 'visibilityjs'; +import { __ } from '../../locale'; import Flash from '../../flash'; import Poll from '../../lib/utils/poll'; -import emptyState from '../components/empty_state.vue'; -import errorState from '../components/error_state.vue'; -import loadingIcon from '../../vue_shared/components/loading_icon.vue'; -import pipelinesTableComponent from '../components/pipelines_table.vue'; +import EmptyState from '../components/empty_state.vue'; +import SvgBlankState from '../components/blank_state.vue'; +import LoadingIcon from '../../vue_shared/components/loading_icon.vue'; +import PipelinesTableComponent from '../components/pipelines_table.vue'; import eventHub from '../event_hub'; export default { components: { - pipelinesTableComponent, - errorState, - emptyState, - loadingIcon, - }, - computed: { - shouldRenderErrorState() { - return this.hasError && !this.isLoading; - }, + PipelinesTableComponent, + SvgBlankState, + EmptyState, + LoadingIcon, }, data() { return { @@ -85,6 +81,7 @@ export default { this.hasError = true; this.isLoading = false; this.updateGraphDropdown = false; + this.hasMadeRequest = true; }, setIsMakingRequest(isMakingRequest) { this.isMakingRequest = isMakingRequest; @@ -96,7 +93,7 @@ export default { postAction(endpoint) { this.service.postAction(endpoint) .then(() => eventHub.$emit('refreshPipelines')) - .catch(() => new Flash('An error occurred while making the request.')); + .catch(() => Flash(__('An error occurred while making the request.'))); }, }, }; diff --git a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js index e8126ac573d..03bb281395a 100644 --- a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js +++ b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js @@ -1,3 +1,5 @@ +import _ from 'underscore'; +import { s__, n__, sprintf } from '~/locale'; import axios from '../lib/utils/axios_utils'; import PANEL_STATE from './constants'; import { backOff } from '../lib/utils/common_utils'; @@ -20,6 +22,7 @@ export default class PrometheusMetrics { this.$missingEnvVarMetricsList = this.$missingEnvVarPanel.find('.js-missing-var-metrics-list'); this.activeMetricsEndpoint = this.$monitoredMetricsPanel.data('activeMetrics'); + this.helpMetricsPath = this.$monitoredMetricsPanel.data('metrics-help-path'); this.$panelToggle.on('click', e => this.handlePanelToggle(e)); } @@ -59,23 +62,39 @@ export default class PrometheusMetrics { populateActiveMetrics(metrics) { let totalMonitoredMetrics = 0; let totalMissingEnvVarMetrics = 0; + let totalExporters = 0; metrics.forEach((metric) => { - this.$monitoredMetricsList.append(`<li>${metric.group}<span class="badge">${metric.active_metrics}</span></li>`); - totalMonitoredMetrics += metric.active_metrics; - if (metric.metrics_missing_requirements > 0) { - this.$missingEnvVarMetricsList.append(`<li>${metric.group}</li>`); - totalMissingEnvVarMetrics += 1; + if (metric.active_metrics > 0) { + totalExporters += 1; + this.$monitoredMetricsList.append(`<li>${_.escape(metric.group)}<span class="badge">${_.escape(metric.active_metrics)}</span></li>`); + totalMonitoredMetrics += metric.active_metrics; + if (metric.metrics_missing_requirements > 0) { + this.$missingEnvVarMetricsList.append(`<li>${_.escape(metric.group)}</li>`); + totalMissingEnvVarMetrics += 1; + } } }); - this.$monitoredMetricsCount.text(totalMonitoredMetrics); - this.showMonitoringMetricsPanelState(PANEL_STATE.LIST); + if (totalMonitoredMetrics === 0) { + const emptyCommonMetricsText = sprintf(s__('PrometheusService|<p class="text-tertiary">No <a href="%{docsUrl}">common metrics</a> were found</p>'), { + docsUrl: this.helpMetricsPath, + }, false); + this.$monitoredMetricsEmpty.empty(); + this.$monitoredMetricsEmpty.append(emptyCommonMetricsText); + this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY); + } else { + const metricsCountText = sprintf(s__('PrometheusService|%{exporters} with %{metrics} were found'), { + exporters: n__('%d exporter', '%d exporters', totalExporters), + metrics: n__('%d metric', '%d metrics', totalMonitoredMetrics), + }); + this.$monitoredMetricsCount.text(metricsCountText); + this.showMonitoringMetricsPanelState(PANEL_STATE.LIST); - if (totalMissingEnvVarMetrics > 0) { - this.$missingEnvVarPanel.removeClass('hidden'); - this.$missingEnvVarPanel.find('.flash-container').off('click'); - this.$missingEnvVarMetricCount.text(totalMissingEnvVarMetrics); + if (totalMissingEnvVarMetrics > 0) { + this.$missingEnvVarPanel.removeClass('hidden'); + this.$missingEnvVarMetricCount.text(totalMissingEnvVarMetrics); + } } } @@ -97,15 +116,15 @@ export default class PrometheusMetrics { }) .catch(stop); }) - .then((res) => { - if (res && res.data && res.data.length) { - this.populateActiveMetrics(res.data); - } else { + .then((res) => { + if (res && res.data && res.data.length) { + this.populateActiveMetrics(res.data); + } else { + this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY); + } + }) + .catch(() => { this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY); - } - }) - .catch(() => { - this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY); - }); + }); } } diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index b4906ba4ee5..a03180e80e6 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -86,6 +86,7 @@ v-if="repo.location" :text="clipboardText" :title="repo.location" + css-class="btn-default btn-transparent btn-clipboard" /> <div class="controls hidden-xs pull-right"> diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index bef850eddc0..ee4eb3581f3 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -90,6 +90,7 @@ v-if="item.location" :title="item.location" :text="clipboardText(item.location)" + css-class="btn-default btn-transparent btn-clipboard" /> </td> <td> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 8269fe1281d..b58e04b5e60 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -1,3 +1,4 @@ +<script> import Flash from '../../../flash'; import AssigneeTitle from './assignee_title'; import Assignees from './assignees.vue'; @@ -6,11 +7,9 @@ import eventHub from '../../event_hub'; export default { name: 'SidebarAssignees', - data() { - return { - store: new Store(), - loading: false, - }; + components: { + AssigneeTitle, + Assignees, }, props: { mediator: { @@ -27,9 +26,28 @@ export default { default: false, }, }, - components: { - AssigneeTitle, - Assignees, + data() { + return { + store: new Store(), + loading: false, + }; + }, + created() { + this.removeAssignee = this.store.removeAssignee.bind(this.store); + this.addAssignee = this.store.addAssignee.bind(this.store); + this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store); + + // Get events from glDropdown + eventHub.$on('sidebar.removeAssignee', this.removeAssignee); + eventHub.$on('sidebar.addAssignee', this.addAssignee); + eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$on('sidebar.saveAssignees', this.saveAssignees); + }, + beforeDestroy() { + eventHub.$off('sidebar.removeAssignee', this.removeAssignee); + eventHub.$off('sidebar.addAssignee', this.addAssignee); + eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); + eventHub.$off('sidebar.saveAssignees', this.saveAssignees); }, methods: { assignSelf() { @@ -54,39 +72,24 @@ export default { }); }, }, - created() { - this.removeAssignee = this.store.removeAssignee.bind(this.store); - this.addAssignee = this.store.addAssignee.bind(this.store); - this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store); - - // Get events from glDropdown - eventHub.$on('sidebar.removeAssignee', this.removeAssignee); - eventHub.$on('sidebar.addAssignee', this.addAssignee); - eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees); - eventHub.$on('sidebar.saveAssignees', this.saveAssignees); - }, - beforeDestroy() { - eventHub.$off('sidebar.removeAssignee', this.removeAssignee); - eventHub.$off('sidebar.addAssignee', this.addAssignee); - eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees); - eventHub.$off('sidebar.saveAssignees', this.saveAssignees); - }, - template: ` - <div> - <assignee-title - :number-of-assignees="store.assignees.length" - :loading="loading || store.isFetching.assignees" - :editable="store.editable" - :show-toggle="!signedIn" - /> - <assignees - v-if="!store.isFetching.assignees" - class="value" - :root-path="store.rootPath" - :users="store.assignees" - :editable="store.editable" - @assign-self="assignSelf" - /> - </div> - `, }; +</script> + +<template> + <div> + <assignee-title + :number-of-assignees="store.assignees.length" + :loading="loading || store.isFetching.assignees" + :editable="store.editable" + :show-toggle="!signedIn" + /> + <assignees + v-if="!store.isFetching.assignees" + class="value" + :root-path="store.rootPath" + :users="store.assignees" + :editable="store.editable" + @assign-self="assignSelf" + /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 56cc78ca0ca..ef748f18301 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking'; -import SidebarAssignees from './components/assignees/sidebar_assignees'; +import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; import SidebarMoveIssue from './lib/sidebar_move_issue'; import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue'; diff --git a/app/assets/javascripts/sortable/sortable_config.js b/app/assets/javascripts/sortable/sortable_config.js new file mode 100644 index 00000000000..43ef5d66422 --- /dev/null +++ b/app/assets/javascripts/sortable/sortable_config.js @@ -0,0 +1,7 @@ +export default { + animation: 200, + forceFallback: true, + fallbackClass: 'is-dragging', + fallbackOnBody: true, + ghostClass: 'is-ghost', +}; diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js index 6b9422b1816..904b0093f7b 100644 --- a/app/assets/javascripts/terminal/terminal.js +++ b/app/assets/javascripts/terminal/terminal.js @@ -6,8 +6,14 @@ constructor(options) { this.options = options || {}; - this.options.cursorBlink = options.cursorBlink || true; - this.options.screenKeys = options.screenKeys || true; + if (!Object.prototype.hasOwnProperty.call(this.options, 'cursorBlink')) { + this.options.cursorBlink = true; + } + + if (!Object.prototype.hasOwnProperty.call(this.options, 'screenKeys')) { + this.options.screenKeys = true; + } + this.container = document.querySelector(options.selector); this.setSocketUrl(); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue index 18a3787857d..3d886e7d628 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue @@ -67,6 +67,7 @@ <clipboard-button :text="branchNameClipboardData" :title="__('Copy branch name to clipboard')" + css-class="btn-default btn-transparent btn-clipboard" /> {{ s__("mrWidget|into") }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue new file mode 100644 index 00000000000..f0298f732ea --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_maintainer_edit.vue @@ -0,0 +1,20 @@ +<script> + export default { + name: 'MRWidgetMaintainerEdit', + props: { + maintainerEditAllowed: { + type: Boolean, + default: false, + required: false, + }, + }, + }; +</script> + +<template> + <section class="mr-info-list mr-links"> + <p v-if="maintainerEditAllowed"> + {{ s__("mrWidget|Allows edits from maintainers") }} + </p> + </section> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index edb3baa39e4..a1bc28873df 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -15,6 +15,7 @@ export { default as WidgetHeader } from './components/mr_widget_header.vue'; export { default as WidgetMergeHelp } from './components/mr_widget_merge_help.vue'; export { default as WidgetPipeline } from './components/mr_widget_pipeline.vue'; export { default as WidgetDeployment } from './components/mr_widget_deployment'; +export { default as WidgetMaintainerEdit } from './components/mr_widget_maintainer_edit.vue'; export { default as WidgetRelatedLinks } from './components/mr_widget_related_links.vue'; export { default as MergedState } from './components/states/mr_widget_merged.vue'; export { default as FailedToMerge } from './components/states/mr_widget_failed_to_merge.vue'; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js index 797f0f6ec0f..df3eb86f35c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -6,6 +6,7 @@ import { WidgetMergeHelp, WidgetPipeline, WidgetDeployment, + WidgetMaintainerEdit, WidgetRelatedLinks, MergedState, ClosedState, @@ -211,6 +212,7 @@ export default { 'mr-widget-merge-help': WidgetMergeHelp, 'mr-widget-pipeline': WidgetPipeline, 'mr-widget-deployment': WidgetDeployment, + 'mr-widget-maintainer-edit': WidgetMaintainerEdit, 'mr-widget-related-links': WidgetRelatedLinks, 'mr-widget-merged': MergedState, 'mr-widget-closed': ClosedState, @@ -251,11 +253,12 @@ export default { :is="componentName" :mr="mr" :service="service" /> + <mr-widget-maintainer-edit + :maintainerEditAllowed="mr.maintainerEditAllowed" /> <mr-widget-related-links v-if="shouldRenderRelatedLinks" :state="mr.state" - :related-links="mr.relatedLinks" - /> + :related-links="mr.relatedLinks" /> </div> <div class="mr-widget-footer" diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 9a750ce42bd..5d07bcf1bb9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -76,6 +76,7 @@ export default class MergeRequestStore { this.canBeMerged = data.can_be_merged || false; this.isMergeAllowed = data.mergeable || false; this.mergeOngoing = data.merge_ongoing; + this.maintainerEditAllowed = data.allow_maintainer_to_push; // Cherry-pick and Revert actions related this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index 3b6c2da1664..cab126a7eca 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -31,7 +31,7 @@ cssClass: { type: String, required: false, - default: 'btn btn-default btn-transparent btn-clipboard', + default: 'btn-default', }, }, }; @@ -40,6 +40,7 @@ <template> <button type="button" + class="btn" :class="cssClass" :title="title" :data-clipboard-text="text" 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 3b17135f0e5..c1dd4d42d9d 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 @@ -88,7 +88,7 @@ export default { </script> <template> - <div class="block labels"> + <div class="block labels js-labels-block"> <dropdown-value-collapsed v-if="showCreate" :labels="context.labels" @@ -104,7 +104,7 @@ export default { </dropdown-value> <div v-if="canEdit" - class="selectbox" + class="selectbox js-selectbox" style="display: none;" > <dropdown-hidden-input 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 ba4c8fba5ec..69d588eb25d 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 @@ -35,7 +35,7 @@ export default { </script> <template> - <div class="hide-collapsed value issuable-show-labels"> + <div class="hide-collapsed value issuable-show-labels js-value"> <span v-if="isEmpty" class="text-secondary" diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index ae517c41cb2..37d33320445 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -14,6 +14,10 @@ color: $gl-text-color-secondary; } +.text-tertiary { + color: $gl-text-color-tertiary; +} + .text-primary, .text-primary:hover { color: $brand-primary; diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 1d7b0b602cc..127583626cf 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -272,7 +272,7 @@ .divider { height: 1px; - margin: 6px 0; + margin: #{$grid-size / 2} 0; padding: 0; background-color: $dropdown-divider-color; diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 88ce119ee3a..cb2f71b0033 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -12,6 +12,12 @@ margin: 0; } + .flash-warning { + @extend .alert; + @extend .alert-warning; + margin: 0; + } + .flash-alert { @extend .alert; @extend .alert-danger; diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index a6b1bf9b099..48b981dd31f 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -2,14 +2,17 @@ background-color: $modal-body-bg; padding: #{3 * $grid-size} #{2 * $grid-size}; - .page-title { - margin-top: 0; - + .page-title, + .modal-title { .color-label { font-size: $gl-font-size; padding: $gl-vert-padding $label-padding-modal; } } + + .page-title { + margin-top: 0; + } } .modal-body { diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 884665d35c7..58700661142 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -369,7 +369,8 @@ } > text { - font-size: 12px; + fill: $theme-gray-600; + font-size: 10px; } } diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index 47672783d5a..a6ca8ed5016 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -205,7 +205,8 @@ } .badge { - font-size: inherit; + font-size: 12px; + line-height: 12px; } .panel-heading .badge-count { diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 352f12a89fd..19dbee84c11 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -1,6 +1,9 @@ module Boards class IssuesController < Boards::ApplicationController include BoardsResponses + include ControllerWithCrossProjectAccessCheck + + requires_cross_project_access if: -> { board&.group_board? } before_action :whitelist_query_limiting, only: [:index, :update] before_action :authorize_read_issue, only: [:index] @@ -64,11 +67,19 @@ module Boards end def issues_finder - IssuesFinder.new(current_user, project_id: board_parent.id) + if board.group_board? + IssuesFinder.new(current_user, group_id: board_parent.id) + else + IssuesFinder.new(current_user, project_id: board_parent.id) + end end def project - board_parent + @project ||= if board.group_board? + Project.find(issue_params[:project_id]) + else + board_parent + end end def move_params diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index db8c362f125..2753f83c3cf 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -56,6 +56,7 @@ module AuthenticatesWithTwoFactor session.delete(:otp_user_id) remember_me(user) if user_params[:remember_me] == '1' + user.save! sign_in(user) else user.increment_failed_attempts! diff --git a/app/controllers/concerns/boards_responses.rb b/app/controllers/concerns/boards_responses.rb index a145049dc7d..da830ec2cb1 100644 --- a/app/controllers/concerns/boards_responses.rb +++ b/app/controllers/concerns/boards_responses.rb @@ -1,10 +1,46 @@ module BoardsResponses + include Gitlab::Utils::StrongMemoize + + def board_params + params.require(:board).permit(:name, :weight, :milestone_id, :assignee_id, label_ids: []) + end + + def parent + strong_memoize(:parent) do + group? ? group : project + end + end + + def boards_path + if group? + group_boards_path(parent) + else + project_boards_path(parent) + end + end + + def board_path(board) + if group? + group_board_path(parent, board) + else + project_board_path(parent, board) + end + end + + def group? + instance_variable_defined?(:@group) + end + def authorize_read_list - authorize_action_for!(board.parent, :read_list) + ability = board.group_board? ? :read_group : :read_list + + authorize_action_for!(board.parent, ability) end def authorize_read_issue - authorize_action_for!(board.parent, :read_issue) + ability = board.group_board? ? :read_group : :read_issue + + authorize_action_for!(board.parent, ability) end def authorize_update_issue @@ -31,6 +67,10 @@ module BoardsResponses respond_with(@board) # rubocop:disable Gitlab/ModuleWithInstanceVariables end + def serialize_as_json(resource) + resource.as_json(only: [:id]) + end + def respond_with(resource) respond_to do |format| format.html diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index 6f4fdcdaa4f..b26a76d2b62 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -4,7 +4,7 @@ module CreatesCommit # rubocop:disable Gitlab/ModuleWithInstanceVariables def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil) - if can?(current_user, :push_code, @project) + if user_access(@project).can_push_to_branch?(branch_name_or_ref) @project_to_commit_into = @project @branch_name ||= @ref else @@ -50,7 +50,7 @@ module CreatesCommit # rubocop:enable Gitlab/ModuleWithInstanceVariables def authorize_edit_tree! - return if can_collaborate_with_project? + return if can_collaborate_with_project?(project, ref: branch_name_or_ref) access_denied! end @@ -123,4 +123,8 @@ module CreatesCommit params[:create_merge_request].present? && (different_project? || @start_branch != @branch_name) # rubocop:disable Gitlab/ModuleWithInstanceVariables end + + def branch_name_or_ref + @branch_name || @ref # rubocop:disable Gitlab/ModuleWithInstanceVariables + end end diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb new file mode 100644 index 00000000000..7c2016f0326 --- /dev/null +++ b/app/controllers/groups/boards_controller.rb @@ -0,0 +1,27 @@ +class Groups::BoardsController < Groups::ApplicationController + include BoardsResponses + + before_action :assign_endpoint_vars + + def index + @boards = Boards::ListService.new(group, current_user).execute + + respond_with_boards + end + + def show + @board = group.boards.find(params[:id]) + + respond_with_board + end + + def assign_endpoint_vars + @boards_endpoint = group_boards_url(group) + @namespace_path = group.to_param + @labels_endpoint = group_labels_url(group) + end + + def serialize_as_json(resource) + resource.as_json(only: [:id]) + end +end diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index ac1d97dc54a..58be330f466 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -35,10 +35,18 @@ class Groups::LabelsController < Groups::ApplicationController def create @label = Labels::CreateService.new(label_params).execute(group: group) - if @label.valid? - redirect_to group_labels_path(@group) - else - render :new + respond_to do |format| + format.html do + if @label.valid? + redirect_to group_labels_path(@group) + else + render :new + end + end + + format.json do + render json: LabelSerializer.new.represent_appearance(@label) + end end end diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 69fb8121ded..eb7d5fca367 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -42,7 +42,9 @@ class Import::GithubController < Import::BaseController target_namespace = find_or_create_namespace(namespace_path, current_user.namespace_path) if can?(current_user, :create_projects, target_namespace) - project = Gitlab::LegacyGithubImport::ProjectCreator.new(repo, project_name, target_namespace, current_user, access_params, type: provider).execute + project = Gitlab::LegacyGithubImport::ProjectCreator + .new(repo, project_name, target_namespace, current_user, access_params, type: provider) + .execute(extra_project_attrs) if project.persisted? render json: ProjectSerializer.new.represent(project) @@ -73,15 +75,15 @@ class Import::GithubController < Import::BaseController end def new_import_url - public_send("new_import_#{provider}_url") # rubocop:disable GitlabSecurity/PublicSend + public_send("new_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend end def status_import_url - public_send("status_import_#{provider}_url") # rubocop:disable GitlabSecurity/PublicSend + public_send("status_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend end def callback_import_url - public_send("callback_import_#{provider}_url") # rubocop:disable GitlabSecurity/PublicSend + public_send("callback_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend end def provider_unauthorized @@ -116,4 +118,12 @@ class Import::GithubController < Import::BaseController def client_options {} end + + def extra_project_attrs + {} + end + + def extra_import_params + {} + end end diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 6025a40348b..6d9b42a2c04 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -6,7 +6,7 @@ class Projects::ApplicationController < ApplicationController before_action :repository layout 'project' - helper_method :repository, :can_collaborate_with_project? + helper_method :repository, :can_collaborate_with_project?, :user_access private @@ -31,11 +31,12 @@ class Projects::ApplicationController < ApplicationController @repository ||= project.repository end - def can_collaborate_with_project?(project = nil) + def can_collaborate_with_project?(project = nil, ref: nil) project ||= @project can?(current_user, :push_code, project) || - (current_user && current_user.already_forked?(project)) + (current_user && current_user.already_forked?(project)) || + user_access(project).can_push_to_branch?(ref) end def authorize_action!(action) @@ -90,4 +91,9 @@ class Projects::ApplicationController < ApplicationController def check_issues_available! return render_404 unless @project.feature_available?(:issues, current_user) end + + def user_access(project) + @user_access ||= {} + @user_access[project] ||= Gitlab::UserAccess.new(current_user, project: project) + end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 405726c017c..0c1c286a0a4 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -9,8 +9,12 @@ class Projects::BlobController < Projects::ApplicationController before_action :require_non_empty_project, except: [:new, :create] before_action :authorize_download_code! - before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy] + + # We need to assign the blob vars before `authorize_edit_tree!` so we can + # validate access to a specific ref. before_action :assign_blob_vars + before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy] + before_action :commit, except: [:new, :create] before_action :blob, except: [:new, :create] before_action :require_branch_head, only: [:edit, :update] @@ -46,7 +50,7 @@ class Projects::BlobController < Projects::ApplicationController end def edit - if can_collaborate_with_project? + if can_collaborate_with_project?(project, ref: @ref) blob.load_all_data! else redirect_to action: 'show' diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index cabafe26357..965cece600e 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -7,13 +7,19 @@ class Projects::BranchesController < Projects::ApplicationController before_action :authorize_download_code! before_action :authorize_push_code!, only: [:new, :create, :destroy, :destroy_all_merged] - def index - @sort = params[:sort].presence || sort_value_recently_updated - @branches = BranchesFinder.new(@repository, params.merge(sort: @sort)).execute - @branches = Kaminari.paginate_array(@branches).page(params[:page]) + # Support legacy URLs + before_action :redirect_for_legacy_index_sort_or_search, only: [:index] + def index respond_to do |format| format.html do + @sort = params[:sort].presence || sort_value_recently_updated + @mode = params[:state].presence || 'overview' + @overview_max_branches = 5 + + # Fetch branches for the specified mode + fetch_branches_by_mode + @refs_pipelines = @project.pipelines.latest_successful_for_refs(@branches.map(&:name)) @merged_branch_names = repository.merged_branch_names(@branches.map(&:name)) @@ -28,7 +34,9 @@ class Projects::BranchesController < Projects::ApplicationController end end format.json do - render json: @branches.map(&:name) + branches = BranchesFinder.new(@repository, params).execute + branches = Kaminari.paginate_array(branches).page(params[:page]) + render json: branches.map(&:name) end end end @@ -123,4 +131,27 @@ class Projects::BranchesController < Projects::ApplicationController context: 'autodeploy' ) end + + def redirect_for_legacy_index_sort_or_search + # Normalize a legacy URL with redirect + if request.format != :json && !params[:state].presence && [:sort, :search, :page].any? { |key| params[key].presence } + redirect_to project_branches_filtered_path(@project, state: 'all'), notice: 'Update your bookmarked URLs as filtered/sorted branches URL has been changed.' + end + end + + def fetch_branches_by_mode + if @mode == 'overview' + # overview mode + @active_branches, @stale_branches = BranchesFinder.new(@repository, sort: sort_value_recently_updated).execute.partition(&:active?) + # Here we get one more branch to indicate if there are more data we're not showing + @active_branches = @active_branches.first(@overview_max_branches + 1) + @stale_branches = @stale_branches.first(@overview_max_branches + 1) + @branches = @active_branches + @stale_branches + else + # active/stale/all view mode + @branches = BranchesFinder.new(@repository, params.merge(sort: @sort)).execute + @branches = @branches.select { |b| b.state.to_s == @mode } if %w[active stale].include?(@mode) + @branches = Kaminari.paginate_array(@branches).page(params[:page]) + end + end end diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb index 1a418d0f15a..b68cdc39cb8 100644 --- a/app/controllers/projects/deployments_controller.rb +++ b/app/controllers/projects/deployments_controller.rb @@ -24,7 +24,7 @@ class Projects::DeploymentsController < Projects::ApplicationController end def additional_metrics - return render_404 unless deployment.has_additional_metrics? + return render_404 unless deployment.has_metrics? respond_to do |format| format.json do diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index e0f4710175f..99790b8e7e8 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -112,12 +112,14 @@ class Projects::LabelsController < Projects::ApplicationController begin return render_404 unless promote_service.execute(@label) + flash[:notice] = "#{@label.title} promoted to group label." respond_to do |format| format.html do - redirect_to(project_labels_path(@project), - notice: 'Label was promoted to a Group Label') + redirect_to(project_labels_path(@project), status: 303) + end + format.json do + render json: { url: project_labels_path(@project) } end - format.js end rescue ActiveRecord::RecordInvalid => e Gitlab::AppLogger.error "Failed to promote label \"#{@label.title}\" to group label" diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 793ae03fb88..67d4ea2ca8f 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -15,6 +15,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont def merge_request_params_attributes [ + :allow_maintainer_to_push, :assignee_id, :description, :force_remove_source_branch, diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index a1af125547c..54e7d81de6a 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -187,7 +187,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo begin @merge_request.environments_for(current_user).map do |environment| project = environment.project - deployment = environment.first_deployment_for(@merge_request.diff_head_commit) + deployment = environment.first_deployment_for(@merge_request.diff_head_sha) stop_url = if environment.stop_action? && can?(current_user, :create_deployment, environment) diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index 75b17d05e22..ff93147d00f 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -70,9 +70,17 @@ class Projects::MilestonesController < Projects::ApplicationController end def promote - promoted_milestone = Milestones::PromoteService.new(project, current_user).execute(milestone) - flash[:notice] = "Milestone has been promoted to group milestone." - redirect_to group_milestone_path(project.group, promoted_milestone.iid) + Milestones::PromoteService.new(project, current_user).execute(milestone) + + flash[:notice] = "#{milestone.title} promoted to group milestone" + respond_to do |format| + format.html do + redirect_to project_milestones_path(project) + end + format.json do + render json: { url: project_milestones_path(project) } + end + end rescue Milestones::PromoteService::PromoteMilestoneError => error redirect_to milestone, alert: error.message end diff --git a/app/controllers/projects/network_controller.rb b/app/controllers/projects/network_controller.rb index 3b10a93e97f..35fec229db7 100644 --- a/app/controllers/projects/network_controller.rb +++ b/app/controllers/projects/network_controller.rb @@ -9,25 +9,22 @@ class Projects::NetworkController < Projects::ApplicationController before_action :assign_commit def show - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37602 - Gitlab::GitalyClient.allow_n_plus_1_calls do - @url = project_network_path(@project, @ref, @options.merge(format: :json)) - @commit_url = project_commit_path(@project, 'ae45ca32').gsub("ae45ca32", "%s") - - respond_to do |format| - format.html do - if @options[:extended_sha1] && !@commit - flash.now[:alert] = "Git revision '#{@options[:extended_sha1]}' does not exist." - end - end + @url = project_network_path(@project, @ref, @options.merge(format: :json)) + @commit_url = project_commit_path(@project, 'ae45ca32').gsub("ae45ca32", "%s") - format.json do - @graph = Network::Graph.new(project, @ref, @commit, @options[:filter_ref]) + respond_to do |format| + format.html do + if @options[:extended_sha1] && !@commit + flash.now[:alert] = "Git revision '#{@options[:extended_sha1]}' does not exist." end end - render + format.json do + @graph = Network::Graph.new(project, @ref, @commit, @options[:filter_ref]) + end end + + render end def assign_commit diff --git a/app/controllers/projects/prometheus/metrics_controller.rb b/app/controllers/projects/prometheus/metrics_controller.rb index b739d0f0f90..1dd886409a5 100644 --- a/app/controllers/projects/prometheus/metrics_controller.rb +++ b/app/controllers/projects/prometheus/metrics_controller.rb @@ -2,11 +2,12 @@ module Projects module Prometheus class MetricsController < Projects::ApplicationController before_action :authorize_admin_project! + before_action :require_prometheus_metrics! def active_common respond_to do |format| format.json do - matched_metrics = prometheus_service.matched_metrics || {} + matched_metrics = prometheus_adapter.query(:matched_metrics) || {} if matched_metrics.any? render json: matched_metrics @@ -19,8 +20,12 @@ module Projects private - def prometheus_service - @prometheus_service ||= project.find_or_initialize_service('prometheus') + def prometheus_adapter + @prometheus_adapter ||= ::Prometheus::AdapterService.new(project).prometheus_adapter + end + + def require_prometheus_metrics! + render_404 unless prometheus_adapter.can_query? end end end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index af9281e0d02..ee197c75764 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -41,11 +41,11 @@ class ProjectsController < Projects::ApplicationController cookies[:issue_board_welcome_hidden] = { path: project_path(@project), value: nil, expires: Time.at(0) } redirect_to( - project_path(@project), + project_path(@project, custom_import_params), notice: _("Project '%{project_name}' was successfully created.") % { project_name: @project.name } ) else - render 'new', locals: { active_tab: ('import' if project_params[:import_url].present?) } + render 'new', locals: { active_tab: active_new_project_tab } end end @@ -103,7 +103,7 @@ class ProjectsController < Projects::ApplicationController def show if @project.import_in_progress? - redirect_to project_import_path(@project) + redirect_to project_import_path(@project, custom_import_params) return end @@ -359,6 +359,14 @@ class ProjectsController < Projects::ApplicationController ] end + def custom_import_params + {} + end + + def active_new_project_tab + project_params[:import_url].present? ? 'import' : 'blank' + end + def repo_exists? project.repository_exists? && !project.empty_repo? diff --git a/app/finders/branches_finder.rb b/app/finders/branches_finder.rb index 852eac3647d..8bb1366867c 100644 --- a/app/finders/branches_finder.rb +++ b/app/finders/branches_finder.rb @@ -1,5 +1,5 @@ class BranchesFinder - def initialize(repository, params) + def initialize(repository, params = {}) @repository = repository @params = params end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 47c8b9b60ed..150f4c7688b 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -150,9 +150,7 @@ class TodosFinder if project? items.where(project: project) else - projects = Project - .public_or_visible_to_user(current_user) - .order_id_desc + projects = Project.public_or_visible_to_user(current_user) items.joins(:project).merge(projects) end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 12b3d9bac1a..275e892b2e6 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -17,23 +17,35 @@ module BoardsHelper end def build_issue_link_base - project_issues_path(@project) + if board.group_board? + "#{group_path(@board.group)}/:project_path/issues" + else + project_issues_path(@project) + end end def board_base_url - project_boards_path(@project) + if board.group_board? + group_boards_url(@group) + else + project_boards_path(@project) + end end def multiple_boards_available? - current_board_parent.multiple_issue_boards_available?(current_user) + current_board_parent.multiple_issue_boards_available? end def current_board_path(board) - @current_board_path ||= project_board_path(current_board_parent, board) + @current_board_path ||= if board.group_board? + group_board_path(current_board_parent, board) + else + project_board_path(current_board_parent, board) + end end def current_board_parent - @current_board_parent ||= @project + @current_board_parent ||= @group || @project end def can_admin_issue? @@ -47,7 +59,8 @@ module BoardsHelper labels: labels_filter_path(true), labels_endpoint: @labels_endpoint, namespace_path: @namespace_path, - project_path: @project&.try(:path) + project_path: @project&.path, + group_path: @group&.path } end @@ -59,7 +72,8 @@ module BoardsHelper field_name: 'issue[assignee_ids][]', first_user: current_user&.username, current_user: 'true', - project_id: @project&.try(:id), + project_id: @project&.id, + group_id: @group&.id, null_user: 'true', multi_select: 'true', 'dropdown-header': dropdown_options[:data][:'dropdown-header'], diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb index 00b9a0e00eb..07b1fc3d7cf 100644 --- a/app/helpers/branches_helper.rb +++ b/app/helpers/branches_helper.rb @@ -1,15 +1,4 @@ module BranchesHelper - def filter_branches_path(options = {}) - exist_opts = { - search: params[:search], - sort: params[:sort] - } - - options = exist_opts.merge(options) - - project_branches_path(@project, @id, options) - end - def project_branches options_for_select(@project.repository.branch_names, @project.default_branch) end diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index e26ce6da030..905e2002592 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -27,7 +27,7 @@ module FormHelper first_user: current_user&.username, null_user: true, current_user: true, - project_id: @project.id, + project_id: @project&.id, field_name: 'issue[assignee_ids][]', default_label: 'Unassigned', 'max-select': 1, diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 7910de73c52..16eceb3f48f 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -129,7 +129,7 @@ module GroupsHelper links = [:overview, :group_members] if can?(current_user, :read_cross_project) - links += [:activity, :issues, :labels, :milestones, :merge_requests] + links += [:activity, :issues, :boards, :labels, :milestones, :merge_requests] end if can?(current_user, :admin_group, @group) diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb index cdcb38b63ee..9149d79ecb8 100644 --- a/app/helpers/import_helper.rb +++ b/app/helpers/import_helper.rb @@ -1,4 +1,8 @@ module ImportHelper + def has_ci_cd_only_params? + false + end + def import_project_target(owner, name) namespace = current_user.can_create_group? ? owner : current_user.namespace_path "#{namespace}/#{name}" @@ -10,6 +14,64 @@ module ImportHelper link_to full_path, url, target: '_blank', rel: 'noopener noreferrer' end + def import_will_timeout_message(_ci_cd_only) + timeout = time_interval_in_words(Gitlab.config.gitlab_shell.git_timeout) + _('The import will time out after %{timeout}. For repositories that take longer, use a clone/push combination.') % { timeout: timeout } + end + + def import_svn_message(_ci_cd_only) + svn_link = link_to _('this document'), help_page_path('user/project/import/svn') + _('To import an SVN repository, check out %{svn_link}.').html_safe % { svn_link: svn_link } + end + + def import_in_progress_title + if @project.forked? + _('Forking in progress') + else + _('Import in progress') + end + end + + def import_wait_and_refresh_message + _('Please wait while we import the repository for you. Refresh at will.') + end + + def import_github_title + _('Import repositories from GitHub') + end + + def import_github_authorize_message + _('To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:') + end + + def import_github_personal_access_token_message + personal_access_token_link = link_to _('Personal Access Token'), 'https://github.com/settings/tokens' + + if github_import_configured? + _('Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import.').html_safe % { personal_access_token_link: personal_access_token_link } + else + _('To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import.').html_safe % { personal_access_token_link: personal_access_token_link } + end + end + + def import_configure_github_admin_message + github_integration_link = link_to 'GitHub integration', help_page_path('integration/github') + + if current_user.admin? + _('Note: As an administrator you may like to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token.').html_safe % { github_integration_link: github_integration_link } + else + _('Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token.').html_safe % { github_integration_link: github_integration_link } + end + end + + def import_githubish_choose_repository_message + _('Choose which repositories you want to import.') + end + + def import_all_githubish_repositories_button_label + _('Import all repositories') + end + private def github_project_url(full_path) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index ce57422f45d..fb4fe1c40b7 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -125,6 +125,19 @@ module MergeRequestsHelper link_to(url[merge_request.project, merge_request], data: data_attrs, &block) end + def allow_maintainer_push_unavailable_reason(merge_request) + return if merge_request.can_allow_maintainer_to_push?(current_user) + + minimum_visibility = [merge_request.target_project.visibility_level, + merge_request.source_project.visibility_level].min + + if minimum_visibility < Gitlab::VisibilityLevel::INTERNAL + _('Not available for private projects') + elsif ProtectedBranch.protected?(merge_request.source_project, merge_request.source_branch) + _('Not available for protected branches') + end + end + def merge_params_ee(merge_request) {} end diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index f6a6d9bebde..b64be89c181 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -49,15 +49,13 @@ module TreeHelper return false unless on_top_of_branch?(project, ref) - can_collaborate_with_project?(project) + can_collaborate_with_project?(project, ref: ref) end def tree_edit_branch(project = @project, ref = @ref) return unless can_edit_tree?(project, ref) - project = project.present(current_user: current_user) - - if project.can_current_user_push_to_branch?(ref) + if user_access(project).can_push_to_branch?(ref) ref else project = tree_edit_project(project) @@ -88,7 +86,16 @@ module TreeHelper end def commit_in_fork_help - "A new branch will be created in your fork and a new merge request will be started." + _("A new branch will be created in your fork and a new merge request will be started.") + end + + def commit_in_single_accessible_branch + branch_name = html_escape(selected_branch) + + message = _("Your changes can be committed to %{branch_name} because a merge "\ + "request is open.") % { branch_name: "<strong>#{branch_name}</strong>" } + + message.html_safe end def path_breadcrumbs(max_links = 6) @@ -125,4 +132,8 @@ module TreeHelper return tree.name end end + + def selected_branch + @branch_name || tree_edit_branch + end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 45d4fb451d8..e4212775956 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -117,7 +117,7 @@ class Notify < BaseMailer if Gitlab::IncomingEmail.enabled? && @sent_notification address = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key)) - address.display_name = @project.name_with_namespace + address.display_name = @project.full_name headers['Reply-To'] = address diff --git a/app/models/board.rb b/app/models/board.rb index 5bb7d3d3722..3cede6fc99a 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -1,20 +1,22 @@ class Board < ActiveRecord::Base + belongs_to :group belongs_to :project has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent validates :project, presence: true, if: :project_needed? + validates :group, presence: true, unless: :project def project_needed? - true + !group end def parent - project + @parent ||= group || project end def group_board? - false + group_id.present? end def backlog_list diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 89ebd63e605..7b25d8c4089 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -1,6 +1,8 @@ module Clusters module Applications class Prometheus < ActiveRecord::Base + include PrometheusAdapter + VERSION = "2.0.0".freeze self.table_name = 'clusters_applications_prometheus' @@ -39,7 +41,7 @@ module Clusters ) end - def proxy_client + def prometheus_client return unless kube_client proxy_url = kube_client.proxy_url('service', service_name, service_port, Gitlab::Kubernetes::Helm::NAMESPACE) diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 7adf1663c35..16efe90fa27 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -56,12 +56,13 @@ module Clusters def specification { "gitlabUrl" => gitlab_url, - "runnerToken" => ensure_runner.token + "runnerToken" => ensure_runner.token, + "runners" => { "privileged" => privileged } } end def content_values - specification.merge(YAML.load_file(chart_values_file)) + YAML.load_file(chart_values_file).deep_merge!(specification) end end end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 1c0046107d7..49eb069016a 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -51,9 +51,6 @@ module Clusters scope :enabled, -> { where(enabled: true) } scope :disabled, -> { where(enabled: false) } - scope :for_environment, -> (env) { where(environment_scope: ['*', '', env.slug]) } - scope :for_all_environments, -> { where(environment_scope: ['*', '']) } - def status_name if provider provider.status_name diff --git a/app/models/commit.rb b/app/models/commit.rb index b9106309142..cceae5efb72 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -9,6 +9,7 @@ class Commit include Mentionable include Referable include StaticModel + include ::Gitlab::Utils::StrongMemoize attr_mentionable :safe_message, pipeline: :single_line @@ -225,11 +226,13 @@ class Commit end def parents - @parents ||= parent_ids.map { |id| project.commit(id) } + @parents ||= parent_ids.map { |oid| Commit.lazy(project, oid) } end def parent - @parent ||= project.commit(self.parent_id) if self.parent_id + strong_memoize(:parent) do + project.commit_by(oid: self.parent_id) if self.parent_id + end end def notes diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index 89d0474a596..faa94204e33 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -1,5 +1,6 @@ module DeploymentPlatform - def deployment_platform + # EE would override this and utilize the extra argument + def deployment_platform(environment: nil) @deployment_platform ||= find_cluster_platform_kubernetes || find_kubernetes_service_integration || diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 4560bc23193..5a566f3ac02 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -223,6 +223,10 @@ module Issuable def to_ability_name model_name.singular end + + def parent_class + ::Project + end end def today? diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb new file mode 100644 index 00000000000..18cbbd871a1 --- /dev/null +++ b/app/models/concerns/prometheus_adapter.rb @@ -0,0 +1,48 @@ +module PrometheusAdapter + extend ActiveSupport::Concern + + included do + include ReactiveCaching + + self.reactive_cache_key = ->(adapter) { [adapter.class.model_name.singular, adapter.id] } + self.reactive_cache_lease_timeout = 30.seconds + self.reactive_cache_refresh_interval = 30.seconds + self.reactive_cache_lifetime = 1.minute + + def prometheus_client + raise NotImplementedError + end + + def prometheus_client_wrapper + Gitlab::PrometheusClient.new(prometheus_client) + end + + def can_query? + prometheus_client.present? + end + + def query(query_name, *args) + return unless can_query? + + query_class = Gitlab::Prometheus::Queries.const_get("#{query_name.to_s.classify}Query") + + args.map!(&:id) + + with_reactive_cache(query_class.name, *args, &query_class.method(:transform_reactive_result)) + end + + # Cache metrics for specific environment + def calculate_reactive_cache(query_class_name, *args) + return unless prometheus_client + + data = Kernel.const_get(query_class_name).new(prometheus_client_wrapper).query(*args) + { + success: true, + data: data, + last_update: Time.now.utc + } + rescue Gitlab::PrometheusClient::Error => err + { success: false, result: err.message } + end + end +end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index b6cf168d60e..66e61c06765 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -98,28 +98,29 @@ class Deployment < ActiveRecord::Base end def has_metrics? - project.monitoring_service.present? + prometheus_adapter&.can_query? end def metrics return {} unless has_metrics? - project.monitoring_service.deployment_metrics(self) - end - - def has_additional_metrics? - project.prometheus_service.present? + metrics = prometheus_adapter.query(:deployment, self) + metrics&.merge(deployment_time: created_at.to_i) || {} end def additional_metrics - return {} unless project.prometheus_service.present? + return {} unless has_metrics? - metrics = project.prometheus_service.additional_deployment_metrics(self) + metrics = prometheus_adapter.query(:additional_metrics_deployment, self) metrics&.merge(deployment_time: created_at.to_i) || {} end private + def prometheus_adapter + environment.prometheus_adapter + end + def ref_path File.join(environment.ref_path, 'deployments', iid.to_s) end diff --git a/app/models/environment.rb b/app/models/environment.rb index f78c21aebe5..2b0a88ac5b4 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -99,8 +99,8 @@ class Environment < ActiveRecord::Base folder_name == "production" end - def first_deployment_for(commit) - ref = project.repository.ref_name_for_sha(ref_path, commit.sha) + def first_deployment_for(commit_sha) + ref = project.repository.ref_name_for_sha(ref_path, commit_sha) return nil unless ref @@ -146,21 +146,19 @@ class Environment < ActiveRecord::Base end def has_metrics? - project.monitoring_service.present? && available? && last_deployment.present? + prometheus_adapter&.can_query? && available? && last_deployment.present? end def metrics - project.monitoring_service.environment_metrics(self) if has_metrics? + prometheus_adapter.query(:environment, self) if has_metrics? end - def has_additional_metrics? - project.prometheus_service.present? && available? && last_deployment.present? + def additional_metrics + prometheus_adapter.query(:additional_metrics_environment, self) if has_metrics? end - def additional_metrics - if has_additional_metrics? - project.prometheus_service.additional_environment_metrics(self) - end + def prometheus_adapter + @prometheus_adapter ||= Prometheus::AdapterService.new(project, deployment_platform).prometheus_adapter end def slug @@ -226,6 +224,10 @@ class Environment < ActiveRecord::Base self.environment_type || self.name end + def deployment_platform + project.deployment_platform(environment: self) + end + private # Slugifying a name may remove the uniqueness guarantee afforded by it being diff --git a/app/models/event.rb b/app/models/event.rb index be0fc7efa9a..17a198d52c7 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -65,6 +65,7 @@ class Event < ActiveRecord::Base # Callbacks after_create :reset_project_activity after_create :set_last_repository_updated_at, if: :push? + after_create :track_user_interacted_projects # Scopes scope :recent, -> { reorder(id: :desc) } @@ -389,4 +390,11 @@ class Event < ActiveRecord::Base Project.unscoped.where(id: project_id) .update_all(last_repository_updated_at: created_at) end + + def track_user_interacted_projects + # Note the call to .available? is due to earlier migrations + # that would otherwise conflict with the call to .track + # (because the table does not exist yet). + UserInteractedProject.track(self) if UserInteractedProject.available? + end end diff --git a/app/models/group.rb b/app/models/group.rb index 201505c3d3c..8d183006c65 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -31,6 +31,7 @@ class Group < Namespace has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :boards has_many :badges, class_name: 'GroupBadge' accepts_nested_attributes_for :variables, allow_destroy: true diff --git a/app/models/label.rb b/app/models/label.rb index 7538f2d8718..de7f1d56c64 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -35,6 +35,7 @@ class Label < ActiveRecord::Base scope :templates, -> { where(template: true) } scope :with_title, ->(title) { where(title: title) } scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) } + scope :on_group_boards, ->(group_id) { with_lists_and_board.where(boards: { group_id: group_id }) } scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) } def self.prioritized(project) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 5bec68ce4f6..c2bae379a94 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -375,15 +375,27 @@ class MergeRequest < ActiveRecord::Base end def diff_start_sha - diff_start_commit.try(:sha) + if persisted? + merge_request_diff.start_commit_sha + else + target_branch_head.try(:sha) + end end def diff_base_sha - diff_base_commit.try(:sha) + if persisted? + merge_request_diff.base_commit_sha + else + branch_merge_base_commit.try(:sha) + end end def diff_head_sha - diff_head_commit.try(:sha) + if persisted? + merge_request_diff.head_commit_sha + else + source_branch_head.try(:sha) + end end # When importing a pull request from GitHub, the old and new branches may no @@ -646,7 +658,7 @@ class MergeRequest < ActiveRecord::Base !ProtectedBranch.protected?(source_project, source_branch) && !source_project.root_ref?(source_branch) && Ability.allowed?(current_user, :push_code, source_project) && - diff_head_commit == source_branch_head + diff_head_sha == source_branch_head.try(:sha) end def should_remove_source_branch? @@ -853,7 +865,7 @@ class MergeRequest < ActiveRecord::Base def can_be_merged_by?(user) access = ::Gitlab::UserAccess.new(user, project: project) - access.can_push_to_branch?(target_branch) || access.can_merge_to_branch?(target_branch) + access.can_update_branch?(target_branch) end def can_be_merged_via_command_line_by?(user) @@ -1075,4 +1087,22 @@ class MergeRequest < ActiveRecord::Base project.merge_requests.merged.where(author_id: author_id).empty? end + + def allow_maintainer_to_push + maintainer_push_possible? && super + end + + alias_method :allow_maintainer_to_push?, :allow_maintainer_to_push + + def maintainer_push_possible? + source_project.present? && for_fork? && + target_project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && + source_project.visibility_level > Gitlab::VisibilityLevel::PRIVATE && + !ProtectedBranch.protected?(source_project, source_branch) + end + + def can_allow_maintainer_to_push?(user) + maintainer_push_possible? && + Ability.allowed?(user, :push_code, source_project) + end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index db274ea8172..e350b675639 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -222,6 +222,11 @@ class Namespace < ActiveRecord::Base has_parent? end + # Overridden on EE module + def multiple_issue_boards_available? + false + end + def full_path_was if parent_id_was.nil? path_was diff --git a/app/models/network/commit.rb b/app/models/network/commit.rb index 9357e55b419..22d48c9e661 100644 --- a/app/models/network/commit.rb +++ b/app/models/network/commit.rb @@ -24,12 +24,7 @@ module Network end def parents(map) - @commit.parents.map do |p| - if map.include?(p.id) - map[p.id] - end - end - .compact + map.values_at(*@commit.parent_ids).compact end end end diff --git a/app/models/note.rb b/app/models/note.rb index d7a67ec277c..787a80f0196 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -81,7 +81,7 @@ class Note < ActiveRecord::Base validates :author, presence: true validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ } - validate unless: [:for_commit?, :importing?, :for_personal_snippet?] do |note| + validate unless: [:for_commit?, :importing?, :skip_project_check?] do |note| unless note.noteable.try(:project) == note.project errors.add(:project, 'does not match noteable project') end @@ -228,7 +228,7 @@ class Note < ActiveRecord::Base end def skip_project_check? - for_personal_snippet? + !for_project_noteable? end def commit @@ -308,6 +308,11 @@ class Note < ActiveRecord::Base self.noteable.supports_discussions? && !part_of_discussion? end + def can_create_todo? + # Skip system notes, and notes on project snippet + !system? && !for_snippet? + end + def discussion_class(noteable = nil) # When commit notes are rendered on an MR's Discussion page, they are # displayed in one discussion instead of individually. diff --git a/app/models/project.rb b/app/models/project.rb index a11b1e4f554..5f9d9785d64 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -150,6 +150,7 @@ class Project < ActiveRecord::Base # Merge Requests for target project should be removed with it has_many :merge_requests, foreign_key: 'target_project_id' + has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest' has_many :issues has_many :labels, class_name: 'ProjectLabel' has_many :services @@ -276,7 +277,8 @@ class Project < ActiveRecord::Base scope :without_storage_feature, ->(feature) { where('storage_version < :version OR storage_version IS NULL', version: HASHED_STORAGE_FEATURES[feature]) } scope :with_unmigrated_storage, -> { where('storage_version < :version OR storage_version IS NULL', version: LATEST_STORAGE_VERSION) } - scope :sorted_by_activity, -> { reorder(last_activity_at: :desc) } + # last_activity_at is throttled every minute, but last_repository_updated_at is updated with every push + scope :sorted_by_activity, -> { reorder("GREATEST(COALESCE(last_activity_at, '1970-01-01'), COALESCE(last_repository_updated_at, '1970-01-01')) DESC") } scope :sorted_by_stars, -> { reorder('projects.star_count DESC') } scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) } @@ -778,7 +780,7 @@ class Project < ActiveRecord::Base end def last_activity_date - last_repository_updated_at || last_activity_at || updated_at + [last_activity_at, last_repository_updated_at, updated_at].compact.max end def project_id @@ -1527,16 +1529,34 @@ class Project < ActiveRecord::Base end end + def import_export_shared + @import_export_shared ||= Gitlab::ImportExport::Shared.new(self) + end + def export_path return nil unless namespace.present? || hashed_storage?(:repository) - File.join(Gitlab::ImportExport.storage_path, disk_path) + import_export_shared.archive_path end def export_project_path Dir.glob("#{export_path}/*export.tar.gz").max_by { |f| File.ctime(f) } end + def export_status + if export_in_progress? + :started + elsif export_project_path + :finished + else + :none + end + end + + def export_in_progress? + import_export_shared.active_export_count > 0 + end + def remove_exports return nil unless export_path.present? @@ -1665,8 +1685,9 @@ class Project < ActiveRecord::Base end end - def multiple_issue_boards_available?(user) - feature_available?(:multiple_issue_boards, user) + # Overridden on EE module + def multiple_issue_boards_available? + false end def issue_board_milestone_available?(user = nil) @@ -1779,6 +1800,33 @@ class Project < ActiveRecord::Base Badge.where("id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection end + def merge_requests_allowing_push_to_user(user) + return MergeRequest.none unless user + + developer_access_exists = user.project_authorizations + .where('access_level >= ? ', Gitlab::Access::DEVELOPER) + .where('project_authorizations.project_id = merge_requests.target_project_id') + .limit(1) + .select(1) + source_of_merge_requests.opened + .where(allow_maintainer_to_push: true) + .where('EXISTS (?)', developer_access_exists) + end + + def branch_allows_maintainer_push?(user, branch_name) + return false unless user + + cache_key = "user:#{user.id}:#{branch_name}:branch_allows_push" + + memoized_results = strong_memoize(:branch_allows_maintainer_push) do + Hash.new do |result, cache_key| + result[cache_key] = fetch_branch_allows_maintainer_push?(user, branch_name) + end + end + + memoized_results[cache_key] + end + private def storage @@ -1901,4 +1949,22 @@ class Project < ActiveRecord::Base raise ex end + + def fetch_branch_allows_maintainer_push?(user, branch_name) + check_access = -> do + merge_request = source_of_merge_requests.opened + .where(allow_maintainer_to_push: true) + .find_by(source_branch: branch_name) + + merge_request&.can_be_merged_by?(user) + end + + if RequestStore.active? + RequestStore.fetch("project-#{id}:branch-#{branch_name}:user-#{user.id}:branch_allows_maintainer_push") do + check_access.call + end + else + check_access.call + end + end end diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb index ee9cd78327a..9af68b4e821 100644 --- a/app/models/project_services/monitoring_service.rb +++ b/app/models/project_services/monitoring_service.rb @@ -9,11 +9,11 @@ class MonitoringService < Service %w() end - def environment_metrics(environment) + def can_query? raise NotImplementedError end - def deployment_metrics(deployment) + def query(_, *_) raise NotImplementedError end end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 58731451429..dcaeb65dc32 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -1,9 +1,5 @@ class PrometheusService < MonitoringService - include ReactiveService - - self.reactive_cache_lease_timeout = 30.seconds - self.reactive_cache_refresh_interval = 30.seconds - self.reactive_cache_lifetime = 1.minute + include PrometheusAdapter # Access to prometheus is directly through the API prop_accessor :api_url @@ -13,7 +9,7 @@ class PrometheusService < MonitoringService validates :api_url, url: true end - before_save :synchronize_service_state! + before_save :synchronize_service_state after_save :clear_reactive_cache! @@ -66,63 +62,15 @@ class PrometheusService < MonitoringService # Check we can connect to the Prometheus API def test(*args) - client.ping + Gitlab::PrometheusClient.new(prometheus_client).ping { success: true, result: 'Checked API endpoint' } rescue Gitlab::PrometheusClient::Error => err { success: false, result: err } end - def environment_metrics(environment) - with_reactive_cache(Gitlab::Prometheus::Queries::EnvironmentQuery.name, environment.id, &rename_field(:data, :metrics)) - end - - def deployment_metrics(deployment) - metrics = with_reactive_cache(Gitlab::Prometheus::Queries::DeploymentQuery.name, deployment.environment.id, deployment.id, &rename_field(:data, :metrics)) - metrics&.merge(deployment_time: deployment.created_at.to_i) || {} - end - - def additional_environment_metrics(environment) - with_reactive_cache(Gitlab::Prometheus::Queries::AdditionalMetricsEnvironmentQuery.name, environment.id, &:itself) - end - - def additional_deployment_metrics(deployment) - with_reactive_cache(Gitlab::Prometheus::Queries::AdditionalMetricsDeploymentQuery.name, deployment.environment.id, deployment.id, &:itself) - end - - def matched_metrics - with_reactive_cache(Gitlab::Prometheus::Queries::MatchedMetricsQuery.name, &:itself) - end - - # Cache metrics for specific environment - def calculate_reactive_cache(query_class_name, *args) - return unless active? && project && !project.pending_delete? - - environment_id = args.first - client = client(environment_id) - - data = Kernel.const_get(query_class_name).new(client).query(*args) - { - success: true, - data: data, - last_update: Time.now.utc - } - rescue Gitlab::PrometheusClient::Error => err - { success: false, result: err.message } - end - - def client(environment_id = nil) - if manual_configuration? - Gitlab::PrometheusClient.new(RestClient::Resource.new(api_url)) - else - cluster = cluster_with_prometheus(environment_id) - raise Gitlab::PrometheusClient::Error, "couldn't find cluster with Prometheus installed" unless cluster - - rest_client = client_from_cluster(cluster) - raise Gitlab::PrometheusClient::Error, "couldn't create proxy Prometheus client" unless rest_client - - Gitlab::PrometheusClient.new(rest_client) - end + def prometheus_client + RestClient::Resource.new(api_url) if api_url && manual_configuration? && active? end def prometheus_installed? @@ -134,32 +82,7 @@ class PrometheusService < MonitoringService private - def cluster_with_prometheus(environment_id = nil) - clusters = if environment_id - ::Environment.find_by(id: environment_id).try do |env| - # sort results by descending order based on environment_scope being longer - # thus more closely matching environment slug - project.clusters.enabled.for_environment(env).sort_by { |c| c.environment_scope&.length }.reverse! - end - else - project.clusters.enabled.for_all_environments - end - - clusters&.detect { |cluster| cluster.application_prometheus&.installed? } - end - - def client_from_cluster(cluster) - cluster.application_prometheus.proxy_client - end - - def rename_field(old_field, new_field) - -> (metrics) do - metrics[new_field] = metrics.delete(old_field) - metrics - end - end - - def synchronize_service_state! + def synchronize_service_state self.active = prometheus_installed? || manual_configuration? true diff --git a/app/models/project_team.rb b/app/models/project_team.rb index a9e5cfb8240..33280eda0b9 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -85,6 +85,15 @@ class ProjectTeam @masters ||= fetch_members(Gitlab::Access::MASTER) end + def owners + @owners ||= + if group + group.owners + else + [project.owner] + end + end + def import(source_project, current_user = nil) target_project = project diff --git a/app/models/repository.rb b/app/models/repository.rb index 1a14afb951a..461dfbec2bc 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -651,14 +651,15 @@ class Repository end def last_commit_for_path(sha, path) - commit_by(oid: last_commit_id_for_path(sha, path)) + commit = raw_repository.last_commit_for_path(sha, path) + ::Commit.new(commit, @project) if commit end def last_commit_id_for_path(sha, path) key = path.blank? ? "last_commit_id_for_path:#{sha}" : "last_commit_id_for_path:#{sha}:#{Digest::SHA1.hexdigest(path)}" cache.fetch(key) do - raw_repository.last_commit_id_for_path(sha, path) + last_commit_for_path(sha, path)&.id end end @@ -866,20 +867,20 @@ class Repository raw_repository.ancestor?(ancestor_id, descendant_id) end - def fetch_as_mirror(url, forced: false, refmap: :all_refs, remote_name: nil) + def fetch_as_mirror(url, forced: false, refmap: :all_refs, remote_name: nil, prune: true) unless remote_name remote_name = "tmp-#{SecureRandom.hex}" tmp_remote_name = true end add_remote(remote_name, url, mirror_refmap: refmap) - fetch_remote(remote_name, forced: forced) + fetch_remote(remote_name, forced: forced, prune: prune) ensure remove_remote(remote_name) if tmp_remote_name end - def fetch_remote(remote, forced: false, ssh_auth: nil, no_tags: false) - gitlab_shell.fetch_remote(raw_repository, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags) + def fetch_remote(remote, forced: false, ssh_auth: nil, no_tags: false, prune: true) + gitlab_shell.fetch_remote(raw_repository, remote, ssh_auth: ssh_auth, forced: forced, no_tags: no_tags, prune: prune) end def fetch_source_branch!(source_repository, source_branch, local_ref) diff --git a/app/models/snippet.rb b/app/models/snippet.rb index a58c208279e..644120453cf 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -168,5 +168,9 @@ class Snippet < ActiveRecord::Base def search_code(query) fuzzy_search(query, [:content]) end + + def parent_class + ::Project + end end end diff --git a/app/models/todo.rb b/app/models/todo.rb index bb5965e20eb..8afacd188e0 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -32,8 +32,6 @@ class Todo < ActiveRecord::Base validates :target_id, presence: true, unless: :for_commit? validates :commit_id, presence: true, if: :for_commit? - default_scope { reorder(id: :desc) } - scope :pending, -> { with_state(:pending) } scope :done, -> { with_state(:done) } @@ -53,10 +51,14 @@ class Todo < ActiveRecord::Base # milestones, but still show something if the user has a URL with that # selected. def sort(method) - case method.to_s - when 'priority', 'label_priority' then order_by_labels_priority - else order_by(method) - end + sorted = + case method.to_s + when 'priority', 'label_priority' then order_by_labels_priority + else order_by(method) + end + + # Break ties with the ID column for pagination + sorted.order(id: :desc) end # Order by priority depending on which issue/merge request the Todo belongs to diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb new file mode 100644 index 00000000000..dd55a6acb79 --- /dev/null +++ b/app/models/user_interacted_project.rb @@ -0,0 +1,59 @@ +class UserInteractedProject < ActiveRecord::Base + belongs_to :user + belongs_to :project + + validates :project_id, presence: true + validates :user_id, presence: true + + CACHE_EXPIRY_TIME = 1.day + + # Schema version required for this model + REQUIRED_SCHEMA_VERSION = 20180223120443 + + class << self + def track(event) + # For events without a project, we simply don't care. + # An example of this is the creation of a snippet (which + # is not related to any project). + return unless event.project_id + + attributes = { + project_id: event.project_id, + user_id: event.author_id + } + + cached_exists?(attributes) do + transaction(requires_new: true) do + begin + where(attributes).select(1).first || create!(attributes) + true # not caching the whole record here for now + rescue ActiveRecord::RecordNotUnique + # Note, above queries are not atomic and prone + # to race conditions (similar like #find_or_create!). + # In the case where we hit this, the record we want + # already exists - shortcut and return. + true + end + end + end + end + + # Check if we can safely call .track (table exists) + def available? + @available_flag ||= ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION # rubocop:disable Gitlab/PredicateMemoization + end + + # Flushes cached information about schema + def reset_column_information + @available_flag = nil + super + end + + private + + def cached_exists?(project_id:, user_id:, &block) + cache_key = "user_interacted_projects:#{project_id}:#{user_id}" + Rails.cache.fetch(cache_key, expires_in: CACHE_EXPIRY_TIME, &block) + end + end +end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index f0bcba588a2..c9cb730c4e9 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -48,7 +48,12 @@ class GroupPolicy < BasePolicy rule { has_access }.enable :read_namespace rule { developer }.enable :admin_milestones - rule { reporter }.enable :admin_label + + rule { reporter }.policy do + enable :admin_label + enable :admin_list + enable :admin_issue + end rule { master }.policy do enable :create_projects diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 3b0550b4dd6..57ab0c23dcd 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -61,6 +61,11 @@ class ProjectPolicy < BasePolicy desc "Project has request access enabled" condition(:request_access_enabled, scope: :subject) { project.request_access_enabled } + desc "Has merge requests allowing pushes to user" + condition(:has_merge_requests_allowing_pushes, scope: :subject) do + project.merge_requests_allowing_push_to_user(user).any? + end + features = %w[ merge_requests issues @@ -291,6 +296,15 @@ class ProjectPolicy < BasePolicy prevent :read_issue end + # These rules are included to allow maintainers of projects to push to certain + # to run pipelines for the branches they have access to. + rule { can?(:public_access) & has_merge_requests_allowing_pushes }.policy do + enable :create_build + enable :update_build + enable :create_pipeline + enable :update_pipeline + end + private def team_member? diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index 08ae49562c7..9f3f2637183 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -78,7 +78,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end def rebase_path - if !rebase_in_progress? && should_be_rebased? && user_can_push_to_source_branch? + if !rebase_in_progress? && should_be_rebased? && can_push_to_source_branch? rebase_project_merge_request_path(project, merge_request) end end @@ -160,7 +160,11 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end def can_push_to_source_branch? - source_branch_exists? && user_can_push_to_source_branch? + return false unless source_branch_exists? + + !!::Gitlab::UserAccess + .new(current_user, project: source_project) + .can_push_to_branch?(source_branch) end private @@ -191,17 +195,10 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end.sort.to_sentence end - def user_can_push_to_source_branch? - return false unless source_branch_exists? - - ::Gitlab::UserAccess - .new(current_user, project: source_project) - .can_push_to_branch?(source_branch) - end - def user_can_collaborate_with_project? can?(current_user, :push_code, project) || - (current_user && current_user.already_forked?(project)) + (current_user && current_user.already_forked?(project)) || + can_push_to_source_branch? end def user_can_fork_project? diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 4e8ef320af2..4a812e39ee1 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -11,6 +11,7 @@ class MergeRequestWidgetEntity < IssuableEntity expose :source_project_id expose :target_branch expose :target_project_id + expose :allow_maintainer_to_push expose :should_be_rebased?, as: :should_be_rebased expose :ff_only_enabled do |merge_request| @@ -29,6 +30,7 @@ class MergeRequestWidgetEntity < IssuableEntity expose :can_push_to_source_branch do |merge_request| presenter(merge_request).can_push_to_source_branch? end + expose :rebase_path do |merge_request| presenter(merge_request).rebase_path end @@ -38,7 +40,7 @@ class MergeRequestWidgetEntity < IssuableEntity # Diff sha's expose :diff_head_sha do |merge_request| - merge_request.diff_head_sha if merge_request.diff_head_commit + merge_request.diff_head_sha.presence end expose :merge_commit_message @@ -136,8 +138,8 @@ class MergeRequestWidgetEntity < IssuableEntity end expose :new_blob_path do |merge_request| - if can?(current_user, :push_code, merge_request.project) - project_new_blob_path(merge_request.project, merge_request.source_branch) + if presenter(merge_request).can_push_to_source_branch? + project_new_blob_path(merge_request.source_project, merge_request.source_branch) end end diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index 6078fe38064..ecd74b74f8a 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -40,7 +40,11 @@ module Boards end def set_parent - params[:project_id] = parent.id + if parent.is_a?(Group) + params[:group_id] = parent.id + else + params[:project_id] = parent.id + end end def set_state diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index 797d6df7c1a..15fed7d17c1 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -60,8 +60,10 @@ module Boards label_ids = if moving_to_list.movable? moving_from_list.label_id + elsif board.group_board? + ::Label.on_group_boards(parent.id).pluck(:label_id) else - Label.on_project_boards(parent.id).pluck(:label_id) + ::Label.on_project_boards(parent.id).pluck(:label_id) end Array(label_ids).compact diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb index 183556a1d6b..bebc90c7a8d 100644 --- a/app/services/boards/lists/create_service.rb +++ b/app/services/boards/lists/create_service.rb @@ -12,7 +12,11 @@ module Boards private def available_labels_for(board) - LabelsFinder.new(current_user, project_id: parent.id).execute + if board.group_board? + parent.labels + else + LabelsFinder.new(current_user, project_id: parent.id).execute + end end def next_position(board) diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index c8b112132b3..3b3d9239086 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -81,7 +81,7 @@ module Ci end def related_merge_requests - MergeRequest.opened.where(source_project: pipeline.project, source_branch: pipeline.ref) + pipeline.project.source_of_merge_requests.opened.where(source_branch: pipeline.ref) end end end diff --git a/app/services/ci/create_trace_artifact_service.rb b/app/services/ci/create_trace_artifact_service.rb deleted file mode 100644 index ffde824972c..00000000000 --- a/app/services/ci/create_trace_artifact_service.rb +++ /dev/null @@ -1,36 +0,0 @@ -module Ci - class CreateTraceArtifactService < BaseService - def execute(job) - return if job.job_artifacts_trace - - job.trace.read do |stream| - break unless stream.file? - - clone_file!(stream.path, JobArtifactUploader.workhorse_upload_path) do |clone_path| - create_job_trace!(job, clone_path) - FileUtils.rm(stream.path) - end - end - end - - private - - def create_job_trace!(job, path) - File.open(path) do |stream| - job.create_job_artifacts_trace!( - project: job.project, - file_type: :trace, - file: stream) - end - end - - def clone_file!(src_path, temp_dir) - FileUtils.mkdir_p(temp_dir) - Dir.mktmpdir('tmp-trace', temp_dir) do |dir_path| - temp_path = File.join(dir_path, "job.log") - FileUtils.copy(src_path, temp_path) - yield(temp_path) - end - end - end -end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 23262b62615..231ab76fde4 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -35,6 +35,14 @@ module MergeRequests end end + def filter_params(merge_request) + super + + unless merge_request.can_allow_maintainer_to_push?(current_user) + params.delete(:allow_maintainer_to_push) + end + end + def merge_request_metrics_service(merge_request) MergeRequestMetricsService.new(merge_request.metrics) end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 4b186d93772..a98bbdf74dd 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -6,6 +6,7 @@ module MergeRequests @params_issue_iid = params.delete(:issue_iid) self.merge_request = MergeRequest.new(params) + merge_request.author = current_user merge_request.compare_commits = [] merge_request.source_project = find_source_project merge_request.target_project = find_target_project diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb index abf25bb778b..77e7b8a5ea7 100644 --- a/app/services/notes/build_service.rb +++ b/app/services/notes/build_service.rb @@ -26,14 +26,19 @@ module Notes if project project.notes.find_discussion(discussion_id) else - # only PersonalSnippets can have discussions without project association discussion = Note.find_discussion(discussion_id) noteable = discussion.noteable - return nil unless noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable) + return nil unless noteable_without_project?(noteable) discussion end end + + def noteable_without_project?(noteable) + return true if noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable) + + false + end end end diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index 6a10e172483..ad3dcc5010b 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -11,7 +11,7 @@ module Notes unless @note.system? EventCreateService.new.leave_note(@note, @note.author) - return if @note.for_personal_snippet? + return unless @note.for_project_noteable? @note.create_cross_references! execute_note_hooks diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 6835b14648b..e4be953e810 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -280,7 +280,7 @@ module NotificationRecipientService add_participants(note.author) add_mentions(note.author, target: note) - unless note.for_personal_snippet? + if note.for_project_noteable? # Merge project watchers add_project_watchers diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 01838ec6b5d..7fa1387084c 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -85,7 +85,7 @@ module Projects end def after_create_actions - log_info("#{@project.owner.name} created a new project \"#{@project.name_with_namespace}\"") + log_info("#{@project.owner.name} created a new project \"#{@project.full_name}\"") unless @project.gitlab_project_import? @project.write_repository_config diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index fe4e8ea10bf..af41ce82f65 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -2,7 +2,7 @@ module Projects module ImportExport class ExportService < BaseService def execute(_options = {}) - @shared = Gitlab::ImportExport::Shared.new(relative_path: File.join(project.disk_path, 'work')) + @shared = project.import_export_shared save_all end diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index c760bd3b626..00fdd047208 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -1,5 +1,8 @@ module Projects class UpdatePagesService < BaseService + InvaildStateError = Class.new(StandardError) + FailedToExtractError = Class.new(StandardError) + BLOCK_SIZE = 32.kilobytes MAX_SIZE = 1.terabyte SITE_PATH = 'public/'.freeze @@ -11,13 +14,15 @@ module Projects end def execute + register_attempt + # Create status notifying the deployment of pages @status = create_status @status.enqueue! @status.run! - raise 'missing pages artifacts' unless build.artifacts? - raise 'pages are outdated' unless latest? + raise InvaildStateError, 'missing pages artifacts' unless build.artifacts? + raise InvaildStateError, 'pages are outdated' unless latest? # Create temporary directory in which we will extract the artifacts FileUtils.mkdir_p(tmp_path) @@ -26,24 +31,22 @@ module Projects # Check if we did extract public directory archive_public_path = File.join(archive_path, 'public') - raise 'pages miss the public folder' unless Dir.exist?(archive_public_path) - raise 'pages are outdated' unless latest? + raise FailedToExtractError, 'pages miss the public folder' unless Dir.exist?(archive_public_path) + raise InvaildStateError, 'pages are outdated' unless latest? deploy_page!(archive_public_path) success end - rescue => e + rescue InvaildStateError, FailedToExtractError => e register_failure error(e.message) - ensure - register_attempt - build.erase_artifacts! unless build.has_expiring_artifacts? end private def success @status.success + delete_artifact! super end @@ -52,6 +55,7 @@ module Projects @status.allow_failure = !latest? @status.description = message @status.drop(:script_failure) + delete_artifact! super end @@ -72,7 +76,7 @@ module Projects elsif artifacts.ends_with?('.zip') extract_zip_archive!(temp_path) else - raise 'unsupported artifacts format' + raise FailedToExtractError, 'unsupported artifacts format' end end @@ -81,17 +85,17 @@ module Projects %W(dd bs=#{BLOCK_SIZE} count=#{blocks}), %W(tar -x -C #{temp_path} #{SITE_PATH}), err: '/dev/null') - raise 'pages failed to extract' unless results.compact.all?(&:success?) + raise FailedToExtractError, 'pages failed to extract' unless results.compact.all?(&:success?) end def extract_zip_archive!(temp_path) - raise 'missing artifacts metadata' unless build.artifacts_metadata? + raise FailedToExtractError, 'missing artifacts metadata' unless build.artifacts_metadata? # Calculate page size after extract public_entry = build.artifacts_metadata_entry(SITE_PATH, recursive: true) if public_entry.total_size > max_size - raise "artifacts for pages are too large: #{public_entry.total_size}" + raise FailedToExtractError, "artifacts for pages are too large: #{public_entry.total_size}" end # Requires UnZip at least 6.00 Info-ZIP. @@ -100,7 +104,7 @@ module Projects # We add * to end of SITE_PATH, because we want to extract SITE_PATH and all subdirectories site_path = File.join(SITE_PATH, '*') unless system(*%W(unzip -qq -n #{artifacts} #{site_path} -d #{temp_path})) - raise 'pages failed to extract' + raise FailedToExtractError, 'pages failed to extract' end end @@ -163,6 +167,11 @@ module Projects build.artifacts_file.path end + def delete_artifact! + build.reload # Reload stable object to prevent erase artifacts with old state + build.erase_artifacts! unless build.has_expiring_artifacts? + end + def latest_sha project.commit(build.ref).try(:sha).to_s end diff --git a/app/services/prometheus/adapter_service.rb b/app/services/prometheus/adapter_service.rb new file mode 100644 index 00000000000..4504d2ccfe6 --- /dev/null +++ b/app/services/prometheus/adapter_service.rb @@ -0,0 +1,36 @@ +module Prometheus + class AdapterService + def initialize(project, deployment_platform = nil) + @project = project + + @deployment_platform = if deployment_platform + deployment_platform + else + project.deployment_platform + end + end + + attr_reader :deployment_platform, :project + + def prometheus_adapter + @prometheus_adapter ||= if service_prometheus_adapter.can_query? + service_prometheus_adapter + else + cluster_prometheus_adapter + end + end + + def service_prometheus_adapter + project.find_or_initialize_service('prometheus') + end + + def cluster_prometheus_adapter + return unless deployment_platform.respond_to?(:cluster) + + cluster = deployment_platform.cluster + return unless cluster.application_prometheus&.installed? + + cluster.application_prometheus + end + end +end diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index af8c02a10b7..ba7946fd23c 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -20,8 +20,8 @@ class SystemHooksService def build_event_data(model, event) data = { event_name: build_event_name(model, event), - created_at: model.created_at.xmlschema, - updated_at: model.updated_at.xmlschema + created_at: model.created_at&.xmlschema, + updated_at: model.updated_at&.xmlschema } case model diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index c2ca404b179..ffd48e842c2 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -241,8 +241,7 @@ class TodoService end def handle_note(note, author, skip_users = []) - # Skip system notes, and notes on project snippet - return if note.system? || note.for_snippet? + return unless note.can_create_todo? project = note.project target = note.noteable diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb index b71002433d6..06b604dad4d 100644 --- a/app/services/users/destroy_service.rb +++ b/app/services/users/destroy_service.rb @@ -49,6 +49,8 @@ module Users ::Projects::DestroyService.new(project, current_user, skip_repo: project.legacy_storage?).execute end + yield(user) if block_given? + MigrateToGhostUserService.new(user).execute unless options[:hard_delete] # Destroy the namespace after destroying the user since certain methods may depend on the namespace existing diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 68788134b8e..81d7db04a3c 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -657,9 +657,11 @@ .checkbox = f.label :version_check_enabled do = f.check_box :version_check_enabled - Version check enabled + Enable version check .help-block - Let GitLab inform you when an update is available. + GitLab will inform you if a new version is available. + = link_to 'Learn more', help_page_path("user/admin_area/settings/usage_statistics", anchor: "version-check") + about what information is shared with GitLab Inc. .form-group .col-sm-offset-2.col-sm-10 - can_be_configured = @application_setting.usage_ping_can_be_configured? diff --git a/app/views/admin/hooks/_form.html.haml b/app/views/admin/hooks/_form.html.haml index d8f96ed5b0d..a6324a97fd5 100644 --- a/app/views/admin/hooks/_form.html.haml +++ b/app/views/admin/hooks/_form.html.haml @@ -1,21 +1,20 @@ = form_errors(hook) .form-group - = form.label :url, 'URL', class: 'control-label' - .col-sm-10 - = form.text_field :url, class: 'form-control' + = form.label :url, 'URL', class: 'label-light' + = form.text_field :url, class: 'form-control' .form-group - = form.label :token, 'Secret Token', class: 'control-label' - .col-sm-10 - = form.text_field :token, class: 'form-control' - %p.help-block - Use this token to validate received payloads + = form.label :token, 'Secret Token', class: 'label-light' + = form.text_field :token, class: 'form-control' + %p.help-block + Use this token to validate received payloads .form-group - = form.label :url, 'Trigger', class: 'control-label' - .col-sm-10.prepend-top-10 - %div - System hook will be triggered on set of events like creating project - or adding ssh key. But you can also enable extra triggers like Push events. + = form.label :url, 'Trigger', class: 'label-light' + %ul.list-unstyled + %li + .help-block + System hook will be triggered on set of events like creating project + or adding ssh key. But you can also enable extra triggers like Push events. .prepend-top-default = form.check_box :repository_update_events, class: 'pull-left' @@ -24,21 +23,21 @@ %strong Repository update events %p.light This URL will be triggered when repository is updated - %div + %li = form.check_box :push_events, class: 'pull-left' .prepend-left-20 = form.label :push_events, class: 'list-label' do %strong Push events %p.light This URL will be triggered for each branch updated to the repository - %div + %li = form.check_box :tag_push_events, class: 'pull-left' .prepend-left-20 = form.label :tag_push_events, class: 'list-label' do %strong Tag push events %p.light This URL will be triggered when a new tag is pushed to the repository - %div + %li = form.check_box :merge_requests_events, class: 'pull-left' .prepend-left-20 = form.label :merge_requests_events, class: 'list-label' do @@ -46,9 +45,8 @@ %p.light This URL will be triggered when a merge request is created/updated/merged .form-group - = form.label :enable_ssl_verification, 'SSL verification', class: 'control-label checkbox' - .col-sm-10 - .checkbox - = form.label :enable_ssl_verification do - = form.check_box :enable_ssl_verification - %strong Enable SSL verification + = form.label :enable_ssl_verification, 'SSL verification', class: 'label-light checkbox' + .checkbox + = form.label :enable_ssl_verification do + = form.check_box :enable_ssl_verification + %strong Enable SSL verification diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml index bc02d9969d6..d9e2ce5e74c 100644 --- a/app/views/admin/hooks/index.html.haml +++ b/app/views/admin/hooks/index.html.haml @@ -1,33 +1,35 @@ - page_title 'System Hooks' -%h3.page-title - System hooks +.row.prepend-top-default + .col-lg-4 + %h4.prepend-top-0 + = page_title + %p + #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be + used for binding events when GitLab creates a User or Project. -%p.light - #{link_to 'System hooks ', help_page_path('system_hooks/system_hooks'), class: 'vlink'} can be - used for binding events when GitLab creates a User or Project. + .col-lg-8.append-bottom-default + = form_for @hook, as: :hook, url: admin_hooks_path do |f| + = render partial: 'form', locals: { form: f, hook: @hook } + = f.submit 'Add system hook', class: 'btn btn-create' -%hr + %hr -= form_for @hook, as: :hook, url: admin_hooks_path, html: { class: 'form-horizontal' } do |f| - = render partial: 'form', locals: { form: f, hook: @hook } - .form-actions - = f.submit 'Add system hook', class: 'btn btn-create' -%hr + - if @hooks.any? + .panel.panel-default + .panel-heading + System hooks (#{@hooks.count}) + %ul.content-list + - @hooks.each do |hook| + %li + .controls + = render 'shared/web_hooks/test_button', triggers: SystemHook.triggers, hook: hook, button_class: 'btn-sm' + = link_to 'Edit', edit_admin_hook_path(hook), class: 'btn btn-sm' + = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm' + .monospace= hook.url + %div + - SystemHook.triggers.each_value do |event| + - if hook.public_send(event) + %span.label.label-gray= event.to_s.titleize + %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'} -- if @hooks.any? - .panel.panel-default - .panel-heading - System hooks (#{@hooks.count}) - %ul.content-list - - @hooks.each do |hook| - %li - .controls - = render 'shared/web_hooks/test_button', triggers: SystemHook.triggers, hook: hook, button_class: 'btn-sm' - = link_to 'Edit', edit_admin_hook_path(hook), class: 'btn btn-sm' - = link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm' - .monospace= hook.url - %div - - SystemHook.triggers.each_value do |event| - - if hook.public_send(event) - %span.label.label-gray= event.to_s.titleize - %span.label.label-gray SSL Verification: #{hook.enable_ssl_verification ? 'enabled' : 'disabled'} += render 'shared/plugins/index' diff --git a/app/views/groups/boards/index.html.haml b/app/views/groups/boards/index.html.haml new file mode 100644 index 00000000000..bb56769bd3f --- /dev/null +++ b/app/views/groups/boards/index.html.haml @@ -0,0 +1 @@ += render "shared/boards/show", board: @boards.first diff --git a/app/views/groups/boards/show.html.haml b/app/views/groups/boards/show.html.haml new file mode 100644 index 00000000000..92838fa4b11 --- /dev/null +++ b/app/views/groups/boards/show.html.haml @@ -0,0 +1 @@ += render "shared/boards/show", board: @board, group: true diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index ca3f018c5e6..36df03302e8 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -2,9 +2,6 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@group.name} issues") -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - - if group_issues_count(state: 'all').zero? = render 'shared/empty_states/issues', project_select_button: true - else diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml index d10efdad53b..ac7e12fcd0b 100644 --- a/app/views/groups/labels/index.html.haml +++ b/app/views/groups/labels/index.html.haml @@ -1,8 +1,10 @@ - page_title 'Labels' +- issuables = ['issues', 'merge requests'] + .top-area.adjust .nav-text - Labels can be applied to issues and merge requests. Group labels are available for any project within the group. + = _("Labels can be applied to %{features}. Group labels are available for any project within the group.") % { features: issuables.to_sentence } .nav-controls - if can?(current_user, :admin_label, @group) @@ -16,4 +18,4 @@ = paginate @labels, theme: 'gitlab' - else .nothing-here-block - No labels created yet. + = _("No labels created yet.") diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml index e9a04e6c122..638c8b5a672 100644 --- a/app/views/import/_githubish_status.html.haml +++ b/app/views/import/_githubish_status.html.haml @@ -2,11 +2,11 @@ - provider_title = Gitlab::ImportSources.title(provider) %p.light - Select projects you want to import. + = import_githubish_choose_repository_message %hr %p = button_tag class: "btn btn-import btn-success js-import-all" do - Import all projects + = import_all_githubish_repositories_button_label = icon("spinner spin", class: "loading-icon") .table-responsive @@ -16,9 +16,9 @@ %colgroup.import-jobs-status-col %thead %tr - %th From #{provider_title} - %th To GitLab - %th Status + %th= _('From %{provider_title}') % { provider_title: provider_title } + %th= _('To GitLab') + %th= _('Status') %tbody - @already_added_projects.each do |project| %tr{ id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}" } @@ -30,10 +30,12 @@ - if project.import_status == 'finished' %span %i.fa.fa-check - done + = _('Done') - elsif project.import_status == 'started' %i.fa.fa-spinner.fa-spin - started + = _('Started') + - elsif project.import_status == 'failed' + = _('Failed') - else = project.human_import_status_name @@ -55,7 +57,9 @@ = text_field_tag :path, repo.name, class: "input-mini form-control", tabindex: 2, autofocus: true, required: true %td.import-actions.job-status = button_tag class: "btn btn-import js-add-to-import" do - Import + = has_ci_cd_only_params? ? _('Connect') : _('Import') = icon("spinner spin", class: "loading-icon") -.js-importer-status{ data: { jobs_import_path: "#{url_for([:jobs, :import, provider])}", import_path: "#{url_for([:import, provider])}" } } +.js-importer-status{ data: { jobs_import_path: "#{url_for([:jobs, :import, provider])}", + import_path: "#{url_for([:import, provider])}", + ci_cd_only: "#{has_ci_cd_only_params?}" } } diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 9c2da3a3eec..ca47ab5f274 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -1,43 +1,31 @@ -- page_title "GitHub Import" +- title = has_ci_cd_only_params? ? _('Connect repositories from GitHub') : _('GitHub import') +- page_title title +- breadcrumb_title title - header_title "Projects", root_path %h3.page-title - = icon 'github', text: 'Import Projects from GitHub' + = icon 'github', text: import_github_title - if github_import_configured? %p - To import a GitHub project, you first need to authorize GitLab to access - the list of your GitHub repositories: + = import_github_authorize_message - = link_to 'List your GitHub repositories', status_import_github_path, class: 'btn btn-success' + = link_to _('List your GitHub repositories'), status_import_github_path, class: 'btn btn-success' %hr %p - - if github_import_configured? - Alternatively, - - else - To import a GitHub project, - you can use a - = succeed '.' do - = link_to 'Personal Access Token', 'https://github.com/settings/tokens' - When you create your Personal Access Token, - you will need to select the <code>repo</code> scope, so we can display a - list of your public and private repositories which are available for import. + = import_github_personal_access_token_message = form_tag personal_access_token_import_github_path, method: :post, class: 'form-inline' do .form-group - = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: "Personal Access Token", size: 40 - = submit_tag 'List your GitHub repositories', class: 'btn btn-success' + = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: _('Personal Access Token'), size: 40 + = submit_tag _('List your GitHub repositories'), class: 'btn btn-success' + + -# EE-specific start + -# EE-specific end - unless github_import_configured? %hr %p - Note: - - if current_user.admin? - As an administrator you may like to configure - - else - Consider asking your GitLab administrator to configure - = link_to 'GitHub integration', help_page_path("integration/github") - which will allow login via GitHub and allow importing projects without - generating a Personal Access Token. + = import_configure_github_admin_message diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml index 0fe578a0036..b00b972d9c9 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -1,6 +1,8 @@ -- page_title "GitHub Import" +- title = has_ci_cd_only_params? ? _('Connect repositories from GitHub') : _('GitHub import') +- page_title title +- breadcrumb_title title - header_title "Projects", root_path %h3.page-title - = icon 'github', text: 'Import Projects from GitHub' + = icon 'github', text: import_github_title = render 'import/githubish_status', provider: 'github' diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index b520f28123f..5ea19c9882d 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -1,6 +1,6 @@ - issues_count = group_issues_count(state: 'opened') - merge_requests_count = group_merge_requests_count(state: 'opened') -- issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index'] +- issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index', 'boards#index', 'boards#show'] .nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } .nav-sidebar-inner-scroll @@ -51,12 +51,19 @@ %strong.fly-out-top-item-name #{ _('Issues') } %span.badge.count.issue_counter.fly-out-badge= number_with_delimiter(issues_count) + %li.divider.fly-out-top-item = nav_link(path: 'groups#issues', html_options: { class: 'home' }) do = link_to issues_group_path(@group), title: 'List' do %span List + - if group_sidebar_link?(:boards) + = nav_link(path: ['boards#index', 'boards#show']) do + = link_to group_boards_path(@group), title: boards_link_text do + %span + = boards_link_text + - if group_sidebar_link?(:labels) = nav_link(path: 'labels#index') do = link_to group_labels_path(@group), title: 'Labels' do diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml index b55dc3dce5c..b387e38c1a6 100644 --- a/app/views/projects/_commit_button.html.haml +++ b/app/views/projects/_commit_button.html.haml @@ -3,6 +3,4 @@ = link_to 'Cancel', cancel_path, class: 'btn btn-cancel', data: {confirm: leave_edit_message} - - unless can?(current_user, :push_code, @project) - .inline.prepend-left-10 - = commit_in_fork_help + = render 'shared/projects/edit_information' diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml index d367bd6be7b..f4b5ef1555e 100644 --- a/app/views/projects/_new_project_fields.html.haml +++ b/app/views/projects/_new_project_fields.html.haml @@ -1,6 +1,8 @@ - visibility_level = params.dig(:project, :visibility_level) || default_project_visibility +- ci_cd_only = local_assigns.fetch(:ci_cd_only, false) .row{ id: project_name_id } + = f.hidden_field :ci_cd_only, value: ci_cd_only .form-group.project-path.col-sm-6 = f.label :namespace_id, class: 'label-light' do %span diff --git a/app/views/projects/blob/_new_dir.html.haml b/app/views/projects/blob/_new_dir.html.haml index 5d48a35dc4c..48ff66900be 100644 --- a/app/views/projects/blob/_new_dir.html.haml +++ b/app/views/projects/blob/_new_dir.html.haml @@ -17,6 +17,4 @@ = submit_tag _("Create directory"), class: 'btn btn-create' = link_to "Cancel", '#', class: "btn btn-cancel", "data-dismiss" => "modal" - - unless can?(current_user, :push_code, @project) - .inline.prepend-left-10 - = commit_in_fork_help + = render 'shared/projects/edit_information' diff --git a/app/views/projects/blob/_upload.html.haml b/app/views/projects/blob/_upload.html.haml index f1324c61500..182d02376bf 100644 --- a/app/views/projects/blob/_upload.html.haml +++ b/app/views/projects/blob/_upload.html.haml @@ -24,6 +24,4 @@ = button_title = link_to _("Cancel"), '#', class: "btn btn-cancel", "data-dismiss" => "modal" - - unless can?(current_user, :push_code, @project) - .inline.prepend-left-10 - = commit_in_fork_help + = render 'shared/projects/edit_information' diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml new file mode 100644 index 00000000000..12e5a8e8d69 --- /dev/null +++ b/app/views/projects/branches/_panel.html.haml @@ -0,0 +1,19 @@ +- branches = local_assigns.fetch(:branches) +- state = local_assigns.fetch(:state) +- panel_title = local_assigns.fetch(:panel_title) +- show_more_text = local_assigns.fetch(:show_more_text) +- project = local_assigns.fetch(:project) +- overview_max_branches = local_assigns.fetch(:overview_max_branches) + +- return unless branches.any? + +.panel.panel-default.prepend-top-10 + .panel-heading + %h4.panel-title + = panel_title + %ul.content-list.all-branches + - branches.first(overview_max_branches).each do |branch| + = render "projects/branches/branch", branch: branch, merged: project.repository.merged_to_root_ref?(branch) + - if branches.size > overview_max_branches + .panel-footer.text-center + = link_to show_more_text, project_branches_filtered_path(project, state: state), id: "state-#{state}", data: { state: state } diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml index fb770764364..5dcc72d8263 100644 --- a/app/views/projects/branches/index.html.haml +++ b/app/views/projects/branches/index.html.haml @@ -3,26 +3,35 @@ %div{ class: container_class } .top-area.adjust - - if can?(current_user, :admin_project, @project) - .nav-text - - project_settings_link = link_to s_('Branches|project settings'), project_protected_branches_path(@project) - = s_('Branches|Protected branches can be managed in %{project_settings_link}').html_safe % { project_settings_link: project_settings_link } + %ul.nav-links.issues-state-filters + %li{ class: active_when(@mode == 'overview') }> + = link_to s_('Branches|Overview'), project_branches_path(@project), title: s_('Branches|Show overview of the branches') + + %li{ class: active_when(@mode == 'active') }> + = link_to s_('Branches|Active'), project_branches_filtered_path(@project, state: 'active'), title: s_('Branches|Show active branches') + + %li{ class: active_when(@mode == 'stale') }> + = link_to s_('Branches|Stale'), project_branches_filtered_path(@project, state: 'stale'), title: s_('Branches|Show stale branches') + + %li{ class: active_when(!%w[overview active stale].include?(@mode)) }> + = link_to s_('Branches|All'), project_branches_filtered_path(@project, state: 'all'), title: s_('Branches|Show all branches') .nav-controls - = form_tag(filter_branches_path, method: :get) do + = form_tag(project_branches_filtered_path(@project, state: 'all'), method: :get) do = search_field_tag :search, params[:search], { placeholder: s_('Branches|Filter by branch name'), id: 'branch-search', class: 'form-control search-text-input input-short', spellcheck: false } - .dropdown.inline> - %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } - %span.light - = branches_sort_options_hash[@sort] - = icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable - %li.dropdown-header - = s_('Branches|Sort by') - - branches_sort_options_hash.each do |value, title| - %li - = link_to title, filter_branches_path(sort: value), class: ("is-active" if @sort == value) + - unless @mode == 'overview' + .dropdown.inline> + %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } + %span.light + = branches_sort_options_hash[@sort] + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable + %li.dropdown-header + = s_('Branches|Sort by') + - branches_sort_options_hash.each do |value, title| + %li + = link_to title, project_branches_filtered_path(@project, state: 'all', search: params[:search], sort: value), class: ("is-active" if @sort == value) - if can? current_user, :push_code, @project = link_to project_merged_branches_path(@project), @@ -35,7 +44,17 @@ = link_to new_project_branch_path(@project), class: 'btn btn-create' do = s_('Branches|New branch') - - if @branches.any? + - if can?(current_user, :admin_project, @project) + - project_settings_link = link_to s_('Branches|project settings'), project_protected_branches_path(@project) + .row-content-block + %h5 + = s_('Branches|Protected branches can be managed in %{project_settings_link}.').html_safe % { project_settings_link: project_settings_link } + + - if @mode == 'overview' && (@active_branches.any? || @stale_branches.any?) + = render "projects/branches/panel", branches: @active_branches, state: 'active', panel_title: s_('Branches|Active branches'), show_more_text: s_('Branches|Show more active branches'), project: @project, overview_max_branches: @overview_max_branches + = render "projects/branches/panel", branches: @stale_branches, state: 'stale', panel_title: s_('Branches|Stale branches'), show_more_text: s_('Branches|Show more stale branches'), project: @project, overview_max_branches: @overview_max_branches + + - elsif @branches.any? %ul.content-list.all-branches - @branches.each do |branch| = render "projects/branches/branch", branch: branch, merged: @merged_branch_names.include?(branch.name) diff --git a/app/views/projects/clusters/_integration_form.html.haml b/app/views/projects/clusters/_integration_form.html.haml index d4c0cd82ce3..db97203a2aa 100644 --- a/app/views/projects/clusters/_integration_form.html.haml +++ b/app/views/projects/clusters/_integration_form.html.haml @@ -21,6 +21,12 @@ = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') .form-group + %h5= s_('ClusterIntegration|Security') + %p + = s_("ClusterIntegration|The default cluster configuration grants access to a wide set of functionalities needed to successfully build and deploy a containerised application.") + = link_to s_("ClusterIntegration|Learn more about security configuration"), help_page_path('user/project/clusters/index.md', anchor: 'security-implications') + + .form-group %h5= s_('ClusterIntegration|Environment scope') %p = s_("ClusterIntegration|Choose which of your project's environments will use this Kubernetes cluster.") diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml index 93407956f56..21e4664d4e4 100644 --- a/app/views/projects/commit/_change.html.haml +++ b/app/views/projects/commit/_change.html.haml @@ -35,6 +35,4 @@ = submit_tag label, class: 'btn btn-create' = link_to _("Cancel"), '#', class: "btn btn-cancel", "data-dismiss" => "modal" - - unless can?(current_user, :push_code, @project) - .inline.prepend-left-10 - = commit_in_fork_help + = render 'shared/projects/edit_information' diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 02395b6eb9b..5041f322612 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -1,7 +1,5 @@ - @no_container = true - page_title "Cycle Analytics" -- content_for :page_specific_javascripts do - = webpack_bundle_tag('common_vue') #cycle-analytics{ class: container_class, "v-cloak" => "true", data: { request_path: project_cycle_analytics_path(@project) } } - if @cycle_analytics_no_data diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index 0d656b25bc8..7ebe617766f 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -2,9 +2,6 @@ - page_title "Environments" - add_to_breadcrumbs("Pipelines", project_pipelines_path(@project)) -- content_for :page_specific_javascripts do - = webpack_bundle_tag("common_vue") - #environments-list-view{ data: { environments_data: environments_list_data, "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s, "can-read-environment" => can?(current_user, :read_environment, @project).to_s, diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index 9d9759ebc5f..c151b5acdf7 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -15,7 +15,8 @@ "empty-getting-started-svg-path": image_path('illustrations/monitoring/getting_started.svg'), "empty-loading-svg-path": image_path('illustrations/monitoring/loading.svg'), "empty-unable-to-connect-svg-path": image_path('illustrations/monitoring/unable_to_connect.svg'), - "additional-metrics": additional_metrics_project_environment_path(@project, @environment, format: :json), + "metrics-endpoint": additional_metrics_project_environment_path(@project, @environment, format: :json), + "deployment-endpoint": project_environment_deployments_path(@project, @environment, format: :json), "project-path": project_path(@project), "tags-path": project_tags_path(@project), - "has-metrics": "#{@environment.has_metrics?}", deployment_endpoint: project_environment_deployments_path(@project, @environment, format: :json) } } + "has-metrics": "#{@environment.has_metrics?}" } } diff --git a/app/views/projects/imports/show.html.haml b/app/views/projects/imports/show.html.haml index 8c490773a56..3b0c828ccd1 100644 --- a/app/views/projects/imports/show.html.haml +++ b/app/views/projects/imports/show.html.haml @@ -1,12 +1,11 @@ -- page_title @project.forked? ? "Forking in progress" : "Import in progress" +- page_title import_in_progress_title + .save-project-loader .center %h2 %i.fa.fa-spinner.fa-spin - - if @project.forked? - Forking in progress. - - else - Import in progress. - - if @project.external_import? + = import_in_progress_title + - if !has_ci_cd_only_params? && @project.external_import? %p.monospace git clone --bare #{@project.safe_import_url} - %p Please wait while we import the repository for you. Refresh at will. + %p + = import_wait_and_refresh_message diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml index fb06ba58c27..c427a9eedc2 100644 --- a/app/views/projects/issues/index.html.haml +++ b/app/views/projects/issues/index.html.haml @@ -4,9 +4,6 @@ - page_title "Issues" - new_issue_email = @project.new_issuable_address(current_user, 'issue') -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - = content_for :meta_tags do = auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@project.name} issues") diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 80e4dce1a80..9c78bade254 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -4,6 +4,7 @@ - can_admin_label = can?(current_user, :admin_label, @project) - if @labels.exists? || @prioritized_labels.exists? + #promote-label-modal %div{ class: container_class } .top-area.adjust .nav-text diff --git a/app/views/projects/merge_requests/index.html.haml b/app/views/projects/merge_requests/index.html.haml index 720ba236434..b2c0d9e1cfa 100644 --- a/app/views/projects/merge_requests/index.html.haml +++ b/app/views/projects/merge_requests/index.html.haml @@ -6,9 +6,6 @@ - page_title "Merge Requests" - new_merge_request_email = @project.new_issuable_address(current_user, 'merge_request') -- content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' - %div{ class: container_class } = render 'projects/last_push' diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml index 6a7bc4b1888..5b0197ed58c 100644 --- a/app/views/projects/milestones/index.html.haml +++ b/app/views/projects/milestones/index.html.haml @@ -13,6 +13,7 @@ .milestones #delete-milestone-modal + #promote-milestone-modal %ul.content-list = render @milestones diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index de381d489c6..b423888c875 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -27,8 +27,15 @@ Edit - if @project.group - = link_to promote_project_milestone_path(@milestone.project, @milestone), title: "Promote to Group Milestone", class: 'btn btn-grouped', data: { confirm: "Promoting #{@milestone.title} will make it available for all projects inside #{@project.group.name}. Existing project milestones with the same name will be merged. This action cannot be reversed.", toggle: "tooltip" }, method: :post do - Promote + %button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal', + target: '#promote-milestone-modal', + milestone_title: @milestone.title, + url: promote_project_milestone_path(@milestone.project, @milestone), + container: 'body' }, + disabled: true, + type: 'button' } + = _('Promote') + #promote-milestone-modal - if @milestone.active? = link_to 'Close milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped" diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 679ba23a4db..8cdb0a6aff4 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -12,11 +12,14 @@ .row.prepend-top-default .col-lg-3.profile-settings-sidebar %h4.prepend-top-0 - New project + = _('New project') %p - A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), #{link_to 'among other things', help_page_path("user/project/index.md", anchor: "projects-features"), target: '_blank'}. + - among_other_things_link = link_to _('among other things'), help_page_path("user/project/index.md", anchor: "projects-features"), target: '_blank' + = _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link } %p - All features are enabled when you create a project, but you can disable the ones you don’t need in the project settings. + = _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.') + -# EE-specific start + -# EE-specific end .md = brand_new_project_guidelines %p @@ -28,36 +31,38 @@ .col-lg-9.js-toggle-container %ul.nav-links.gitlab-tabs{ role: 'tablist' } - %li{ class: ('active' if active_tab == 'blank'), role: 'presentation' } + %li{ class: active_when(active_tab == 'blank'), role: 'presentation' } %a{ href: '#blank-project-pane', id: 'blank-project-tab', data: { toggle: 'tab' }, role: 'tab' } %span.hidden-xs Blank project %span.visible-xs Blank - %li{ class: ('active' if active_tab == 'template'), role: 'presentation' } + %li{ class: active_when(active_tab == 'template'), role: 'presentation' } %a{ href: '#create-from-template-pane', id: 'create-from-template-tab', data: { toggle: 'tab' }, role: 'tab' } %span.hidden-xs Create from template %span.visible-xs Template - %li{ class: ('active' if active_tab == 'import'), role: 'presentation' } + %li{ class: active_when(active_tab == 'import'), role: 'presentation' } %a{ href: '#import-project-pane', id: 'import-project-tab', data: { toggle: 'tab' }, role: 'tab' } %span.hidden-xs Import project %span.visible-xs Import + -# EE-specific start + -# EE-specific end .tab-content.gitlab-tab-content - .tab-pane{ id: 'blank-project-pane', class: ('active' if active_tab == 'blank'), role: 'tabpanel' } + .tab-pane{ id: 'blank-project-pane', class: active_when(active_tab == 'blank'), role: 'tabpanel' } = form_for @project, html: { class: 'new_project' } do |f| = render 'new_project_fields', f: f, project_name_id: "blank-project-name" - .tab-pane.no-padding{ id: 'create-from-template-pane', class: ('active' if active_tab == 'template'), role: 'tabpanel' } + .tab-pane.no-padding{ id: 'create-from-template-pane', class: active_when(active_tab == 'template'), role: 'tabpanel' } = form_for @project, html: { class: 'new_project' } do |f| .project-template .form-group %div = render 'project_templates', f: f - .tab-pane.import-project-pane{ id: 'import-project-pane', class: ('active' if active_tab == 'import'), role: 'tabpanel' } + .tab-pane.import-project-pane.js-toggle-container{ id: 'import-project-pane', class: active_when(active_tab == 'import'), role: 'tabpanel' } = form_for @project, html: { class: 'new_project' } do |f| - if import_sources_enabled? .project-import.row - .col-sm-12 + .col-lg-12 .form-group.import-btn-container.clearfix = f.label :visibility_level, class: 'label-light' do #the label here seems wrong Import project from @@ -68,7 +73,7 @@ = icon('gitlab', text: 'GitLab export') %div - if github_import_enabled? - = link_to new_import_github_path, class: 'btn import_github' do + = link_to new_import_github_path, class: 'btn js-import-github' do = icon('github', text: 'GitHub') %div - if bitbucket_import_enabled? @@ -97,7 +102,7 @@ Gitea %div - if git_import_enabled? - %button.btn.js-toggle-button.import_git{ type: "button" } + %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' } } = icon('git', text: 'Repo by URL') .col-lg-12 .js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') } @@ -105,6 +110,10 @@ = render "shared/import_form", f: f = render 'new_project_fields', f: f, project_name_id: "import-url-name" + + -# EE-specific start + -# EE-specific end + .save-project-loader.hide .center %h2 diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index cf95cdbfec2..3e6b3346787 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -7,8 +7,9 @@ "help-auto-devops-path" => help_page_path('topics/autodevops/index.md'), "empty-state-svg-path" => image_path('illustrations/pipelines_empty.svg'), "error-state-svg-path" => image_path('illustrations/pipelines_failed.svg'), - "new-pipeline-path" => new_project_pipeline_path(@project), + "no-pipelines-svg-path" => image_path('illustrations/pipelines_pending.svg'), "can-create-pipeline" => can?(current_user, :create_pipeline, @project).to_s, - "has-ci" => @repository.gitlab_ci_yml, - "ci-lint-path" => ci_lint_path, - "reset-cache-path" => reset_cache_project_settings_ci_cd_path(@project) } } + "new-pipeline-path" => can?(current_user, :create_pipeline, @project) && new_project_pipeline_path(@project), + "ci-lint-path" => can?(current_user, :create_pipeline, @project) && ci_lint_path, + "reset-cache-path" => can?(current_user, :admin_pipeline, @project) && reset_cache_project_settings_ci_cd_path(@project) , + "has-gitlab-ci" => (@project.has_ci? && @project.builds_enabled?).to_s } } diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index ffb0ae95f9b..a7d7c923957 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -10,6 +10,3 @@ = render "projects/pipelines/with_tabs", pipeline: @pipeline .js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json) } } - -- content_for :page_specific_javascripts do - = webpack_bundle_tag('common_vue') diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 27e1f9fba3e..12d56e244ce 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -14,8 +14,6 @@ .col-lg-12 #js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json) } } - = webpack_bundle_tag('common_vue') - .row.prepend-top-10 .col-lg-12 .panel.panel-default diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml index 6dc2b85fd32..43e6a173108 100644 --- a/app/views/projects/services/prometheus/_show.html.haml +++ b/app/views/projects/services/prometheus/_show.html.haml @@ -7,21 +7,19 @@ = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus') .col-lg-9 - .panel.panel-default.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(@project, :json) } } + .panel.panel-default.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(@project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/metrics') } } .panel-heading %h3.panel-title - = s_('PrometheusService|Monitored') + = s_('PrometheusService|Common metrics') %span.badge.js-monitored-count 0 .panel-body - .loading-metrics.text-center.js-loading-metrics - = icon('spinner spin 3x', class: 'metrics-load-spinner') - %p + .loading-metrics.js-loading-metrics + %p.prepend-top-10.prepend-left-10 + = icon('spinner spin', class: 'metrics-load-spinner') = s_('PrometheusService|Finding and configuring metrics...') - .empty-metrics.text-center.hidden.js-empty-metrics - = custom_icon('icon_empty_metrics') - %p - = s_('PrometheusService|No metrics are being monitored. To start monitoring, deploy to an environment.') - = link_to s_('PrometheusService|View environments'), project_environments_path(@project), class: 'btn btn-success' + .empty-metrics.hidden.js-empty-metrics + %p.text-tertiary.prepend-top-10.prepend-left-10 + = s_('PrometheusService|Waiting for your first deployment to an environment to find common metrics') %ul.list-unstyled.metrics-list.hidden.js-metrics-list .panel.panel-default.hidden.js-panel-missing-env-vars diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml index 235d532bf98..6bef4d19434 100644 --- a/app/views/projects/settings/repository/show.html.haml +++ b/app/views/projects/settings/repository/show.html.haml @@ -2,9 +2,6 @@ - page_title "Repository" - @content_class = "limit-container-width" unless fluid_layout -- content_for :page_specific_javascripts do - = webpack_bundle_tag('common_vue') - -# Protected branches & tags use a lot of nested partials. -# The shared parts of the views can be found in the `shared` directory. -# Those are used throughout the actual views. These `shared` views are then diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index da364b58e36..10415d011d6 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -1,7 +1,6 @@ - @no_container = true - @sort ||= sort_value_recently_updated - page_title s_('TagsPage|Tags') -- add_to_breadcrumbs("Repository", project_tree_path(@project)) .flex-list{ class: container_class } .top-area.adjust diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml index 736afa085e8..5eaaa1448d5 100644 --- a/app/views/shared/_import_form.html.haml +++ b/app/views/shared/_import_form.html.haml @@ -1,17 +1,22 @@ +- ci_cd_only = local_assigns.fetch(:ci_cd_only, false) + .form-group.import-url-data = f.label :import_url, class: 'label-light' do - %span Git repository URL + %span + = _('Git repository URL') - = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git' + = f.text_field :import_url, autocomplete: 'off', class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git', required: true .well.prepend-top-20 %ul %li - The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>. + = _('The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>.').html_safe %li - If your HTTP repository is not publicly accessible, add authentication information to the URL: <code>https://username:password@gitlab.company.com/group/project.git</code>. + = _('If your HTTP repository is not publicly accessible, add authentication information to the URL: <code>https://username:password@gitlab.company.com/group/project.git</code>.').html_safe %li - The import will time out after #{time_interval_in_words(Gitlab.config.gitlab_shell.git_timeout)}. - For repositories that take longer, use a clone/push combination. + = import_will_timeout_message(ci_cd_only) %li - To migrate an SVN repository, check out #{link_to "this document", help_page_path('user/project/import/svn')}. + = import_svn_message(ci_cd_only) + +-# EE-specific start +-# EE-specific end diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml index 8847d11f623..5afbc78df53 100644 --- a/app/views/shared/_label.html.haml +++ b/app/views/shared/_label.html.haml @@ -48,8 +48,16 @@ .pull-right.hidden-xs.hidden-sm - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group) - = link_to promote_project_label_path(label.project, label), title: "Promote to Group Label", class: 'btn btn-transparent btn-action', data: {confirm: "Promoting #{label.title} will make it available for all projects inside #{label.project.group.name}. Existing project labels with the same name will be merged. This action cannot be reversed.", toggle: "tooltip"}, method: :post do - %span.sr-only Promote to Group + %button.js-promote-project-label-button.btn.btn-transparent.btn-action.has-tooltip{ title: _('Promote to Group Label'), + disabled: true, + type: 'button', + data: { url: promote_project_label_path(label.project, label), + label_title: label.title, + label_color: label.color, + label_text_color: label.text_color, + target: '#promote-label-modal', + container: 'body', + toggle: 'modal' } } = sprite_icon('level-up') - if can?(current_user, :admin_label, label) = link_to edit_label_path(label), title: "Edit", class: 'btn btn-transparent btn-action', data: {toggle: "tooltip"} do diff --git a/app/views/shared/_new_commit_form.html.haml b/app/views/shared/_new_commit_form.html.haml index 0a4a24ae807..9221fd1e025 100644 --- a/app/views/shared/_new_commit_form.html.haml +++ b/app/views/shared/_new_commit_form.html.haml @@ -1,3 +1,6 @@ +- project = @project.present(current_user: current_user) +- branch_name = selected_branch + = render 'shared/commit_message_container', placeholder: placeholder - if @project.empty_repo? @@ -7,12 +10,14 @@ .form-group.branch = label_tag 'branch_name', _('Target Branch'), class: 'control-label' .col-sm-10 - = text_field_tag 'branch_name', @branch_name || tree_edit_branch, required: true, class: "form-control js-branch-name ref-name" + = text_field_tag 'branch_name', branch_name, required: true, class: "form-control js-branch-name ref-name" .js-create-merge-request-container = render 'shared/new_merge_request_checkbox' + - elsif project.can_current_user_push_to_branch?(branch_name) + = hidden_field_tag 'branch_name', branch_name - else - = hidden_field_tag 'branch_name', @branch_name || tree_edit_branch + = hidden_field_tag 'branch_name', branch_name = hidden_field_tag 'create_merge_request', 1 = hidden_field_tag 'original_branch', @ref, class: 'js-original-branch' diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index 014b8de1dc9..44b09545a61 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -1,3 +1,5 @@ +- board = local_assigns.fetch(:board, nil) +- group = local_assigns.fetch(:group, false) - @no_breadcrumb_container = true - @no_container = true - @content_class = "issue-boards-content" @@ -5,7 +7,6 @@ - page_title "Boards" - content_for :page_specific_javascripts do - = webpack_bundle_tag 'common_vue' -# haml-lint:disable InlineJavaScript %script#js-board-template{ type: "text/x-template" }= render "shared/boards/components/board" @@ -27,7 +28,7 @@ ":root-path" => "rootPath", ":board-id" => "boardId", ":key" => "_uid" } - = render "shared/boards/components/sidebar" + = render "shared/boards/components/sidebar", group: group - if @project %board-add-issues-modal{ "new-issue-path" => new_project_issue_path(@project), "milestone-path" => milestones_filter_dropdown_path, diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index c687e66fd43..2e9ad380012 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -42,6 +42,7 @@ ":disabled" => "disabled", ":issue-link-base" => "issueLinkBase", ":root-path" => "rootPath", + ":groupId" => ((current_board_parent.id if @group) || 'null'), "ref" => "board-list" } - if can?(current_user, :admin_list, current_board_parent) %board-blank-state{ "v-if" => 'list.id == "blank"' } diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 6dfabd7ba4c..4c8f03f1498 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -33,6 +33,8 @@ = render 'shared/issuable/form/merge_params', issuable: issuable += render 'shared/issuable/form/contribution', issuable: issuable, form: form + - if @merge_request_to_resolve_discussions_of .form-group .col-sm-10.col-sm-offset-2 diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index fabb17c7340..fc6f71ef60f 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -112,6 +112,7 @@ - if can?(current_user, :admin_label, board.parent) = render partial: "shared/issuable/label_page_create" = dropdown_loading - #js-add-issues-btn.prepend-left-10 + - if @project + #js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } - elsif type != :boards_modal = render 'shared/sort_dropdown' diff --git a/app/views/shared/issuable/form/_contribution.html.haml b/app/views/shared/issuable/form/_contribution.html.haml new file mode 100644 index 00000000000..0f2d313a5cc --- /dev/null +++ b/app/views/shared/issuable/form/_contribution.html.haml @@ -0,0 +1,20 @@ +- issuable = local_assigns.fetch(:issuable) +- form = local_assigns.fetch(:form) + +- return unless issuable.is_a?(MergeRequest) +- return unless issuable.for_fork? +- return unless can?(current_user, :push_code, issuable.source_project) + +%hr + +.form-group + .control-label + = _('Contribution') + .col-sm-10 + .checkbox + = form.label :allow_maintainer_to_push do + = form.check_box :allow_maintainer_to_push, disabled: !issuable.can_allow_maintainer_to_push?(current_user) + = _('Allow edits from maintainers') + = link_to 'About this feature', help_page_path('user/project/merge_requests/maintainer_access') + .help-block + = allow_maintainer_push_unavailable_reason(issuable) diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index da01fc02d07..9db2a321526 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -51,8 +51,15 @@ \ - if @project.group - = link_to promote_project_milestone_path(milestone.project, milestone), title: "Promote to Group Milestone", class: 'btn btn-xs btn-grouped', data: { confirm: "Promoting #{milestone.title} will make it available for all projects inside #{@project.group.name}. Existing project milestones with the same name will be merged. This action cannot be reversed.", toggle: "tooltip" }, method: :post do - Promote + %button.js-promote-project-milestone-button.btn.btn-xs.btn-grouped.has-tooltip{ title: _('Promote to Group Milestone'), + disabled: true, + type: 'button', + data: { url: promote_project_milestone_path(milestone.project, milestone), + milestone_title: milestone.title, + target: '#promote-milestone-modal', + container: 'body', + toggle: 'modal' } } + = _('Promote') = link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-xs btn-close btn-grouped" diff --git a/app/views/shared/plugins/_index.html.haml b/app/views/shared/plugins/_index.html.haml new file mode 100644 index 00000000000..fc643c3ecc2 --- /dev/null +++ b/app/views/shared/plugins/_index.html.haml @@ -0,0 +1,23 @@ +- plugins = Gitlab::Plugin.files + +.row.prepend-top-default + .col-lg-4 + %h4.prepend-top-0 + Plugins + %p + #{link_to 'Plugins', help_page_path('administration/plugins')} are similar to + system hooks but are executed as files instead of sending data to a URL. + + .col-lg-8.append-bottom-default + - if plugins.any? + .panel.panel-default + .panel-heading + Plugins (#{plugins.count}) + %ul.content-list + - plugins.each do |file| + %li + .monospace + = File.basename(file) + - else + %p.light-well.text-center + No plugins found. diff --git a/app/views/shared/projects/_edit_information.html.haml b/app/views/shared/projects/_edit_information.html.haml new file mode 100644 index 00000000000..ec9dc8f62c2 --- /dev/null +++ b/app/views/shared/projects/_edit_information.html.haml @@ -0,0 +1,6 @@ +- unless can?(current_user, :push_code, @project) + .inline.prepend-left-10 + - if @project.branch_allows_maintainer_push?(current_user, selected_branch) + = commit_in_single_accessible_branch + - else + = commit_in_fork_help diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 328db19be29..f65e8385ac8 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -43,12 +43,11 @@ - pipeline_cache:expire_pipeline_cache - pipeline_creation:create_pipeline - pipeline_creation:run_pipeline_schedule +- pipeline_background:archive_trace - pipeline_default:build_coverage - pipeline_default:build_trace_sections -- pipeline_default:create_trace_artifact - pipeline_default:pipeline_metrics - pipeline_default:pipeline_notification -- pipeline_default:update_head_pipeline_for_merge_request - pipeline_hooks:build_hooks - pipeline_hooks:pipeline_hooks - pipeline_processing:build_finished @@ -58,6 +57,7 @@ - pipeline_processing:pipeline_success - pipeline_processing:pipeline_update - pipeline_processing:stage_update +- pipeline_processing:update_head_pipeline_for_merge_request - repository_check:repository_check_clear - repository_check:repository_check_single_repository diff --git a/app/workers/archive_trace_worker.rb b/app/workers/archive_trace_worker.rb new file mode 100644 index 00000000000..dea7425ad88 --- /dev/null +++ b/app/workers/archive_trace_worker.rb @@ -0,0 +1,10 @@ +class ArchiveTraceWorker + include ApplicationWorker + include PipelineBackgroundQueue + + def perform(job_id) + Ci::Build.find_by(id: job_id).try do |job| + job.trace.archive! + end + end +end diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index b5ed8d607b3..46f1ac09915 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -12,7 +12,7 @@ class BuildFinishedWorker # We execute that async as this are two indepentent operations that can be executed after TraceSections and Coverage BuildHooksWorker.perform_async(build.id) - CreateTraceArtifactWorker.perform_async(build.id) + ArchiveTraceWorker.perform_async(build.id) end end end diff --git a/app/workers/concerns/pipeline_background_queue.rb b/app/workers/concerns/pipeline_background_queue.rb new file mode 100644 index 00000000000..8bf43de6b26 --- /dev/null +++ b/app/workers/concerns/pipeline_background_queue.rb @@ -0,0 +1,10 @@ +## +# Concern for setting Sidekiq settings for the low priority CI pipeline workers. +# +module PipelineBackgroundQueue + extend ActiveSupport::Concern + + included do + queue_namespace :pipeline_background + end +end diff --git a/app/workers/create_trace_artifact_worker.rb b/app/workers/create_trace_artifact_worker.rb deleted file mode 100644 index 11cda58021e..00000000000 --- a/app/workers/create_trace_artifact_worker.rb +++ /dev/null @@ -1,10 +0,0 @@ -class CreateTraceArtifactWorker - include ApplicationWorker - include PipelineQueue - - def perform(job_id) - Ci::Build.preload(:project, :user).find_by(id: job_id).try do |job| - Ci::CreateTraceArtifactService.new(job.project, job.user).execute(job) - end - end -end diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index 21da27973fe..2a4d65b5cb3 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -66,7 +66,7 @@ class EmailsOnPushWorker # These are input errors and won't be corrected even if Sidekiq retries rescue Net::SMTPFatalError, Net::SMTPSyntaxError => e - logger.info("Failed to send e-mail for project '#{project.name_with_namespace}' to #{recipient}: #{e}") + logger.info("Failed to send e-mail for project '#{project.full_name}' to #{recipient}: #{e}") end end ensure diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb index d3b95009364..66a0ff83bef 100644 --- a/app/workers/pages_worker.rb +++ b/app/workers/pages_worker.rb @@ -1,7 +1,7 @@ class PagesWorker include ApplicationWorker - sidekiq_options retry: false + sidekiq_options retry: 3 def perform(action, *arg) send(action, *arg) # rubocop:disable GitlabSecurity/PublicSend diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index f2b2c4428d3..3909dbf7d7f 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -55,7 +55,7 @@ class PostReceive end def process_wiki_changes(post_received) - # Nothing defined here yet. + post_received.project.touch(:last_activity_at, :last_repository_updated_at) end def log(message) diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb index f09d89aa170..76f84ff920f 100644 --- a/app/workers/update_head_pipeline_for_merge_request_worker.rb +++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb @@ -2,6 +2,8 @@ class UpdateHeadPipelineForMergeRequestWorker include ApplicationWorker include PipelineQueue + queue_namespace :pipeline_processing + def perform(merge_request_id) merge_request = MergeRequest.find(merge_request_id) pipeline = Ci::Pipeline.where(project: merge_request.source_project, ref: merge_request.source_branch).last |