diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2019-12-17 15:08:15 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2019-12-17 15:08:15 +0000 |
commit | c2b98d3dbd47ab92c79c702276fe9130d9a28036 (patch) | |
tree | bf4071f551fdc12c22b23b2bb66483064e7b9ea9 | |
parent | badb9c1deacbea601b02f88811b7e123589d9251 (diff) | |
download | gitlab-ce-c2b98d3dbd47ab92c79c702276fe9130d9a28036.tar.gz |
Add latest changes from gitlab-org/gitlab@master
91 files changed, 1507 insertions, 190 deletions
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index 734a9a89c72..675552e6c2b 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -1,5 +1,6 @@ <script> import icon from '~/vue_shared/components/icon.vue'; +import { GlBadge } from '@gitlab/ui'; import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { ITEM_TYPE, @@ -8,13 +9,16 @@ import { PROJECT_VISIBILITY_TYPE, } from '../constants'; import itemStatsValue from './item_stats_value.vue'; +import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending_removal'; export default { components: { icon, timeAgoTooltip, itemStatsValue, + GlBadge, }, + mixins: [isProjectPendingRemoval], props: { item: { type: Object, @@ -70,6 +74,9 @@ export default { css-class="project-stars" icon-name="star" /> + <div v-if="isProjectPendingRemoval"> + <gl-badge variant="warning">{{ __('pending removal') }}</gl-badge> + </div> <div v-if="isProject" class="last-updated"> <time-ago-tooltip :time="item.updatedAt" tooltip-placement="bottom" /> </div> diff --git a/app/assets/javascripts/groups/mixins/is_project_pending_removal.js b/app/assets/javascripts/groups/mixins/is_project_pending_removal.js new file mode 100644 index 00000000000..e44e5780199 --- /dev/null +++ b/app/assets/javascripts/groups/mixins/is_project_pending_removal.js @@ -0,0 +1,7 @@ +export default { + computed: { + isProjectPendingRemoval() { + return false; + }, + }, +}; diff --git a/app/assets/javascripts/groups/store/groups_store.js b/app/assets/javascripts/groups/store/groups_store.js index 16f95d5a0cc..214ac5e3db5 100644 --- a/app/assets/javascripts/groups/store/groups_store.js +++ b/app/assets/javascripts/groups/store/groups_store.js @@ -93,6 +93,7 @@ export default class GroupsStore { memberCount: rawGroupItem.number_users_with_delimiter, starCount: rawGroupItem.star_count, updatedAt: rawGroupItem.updated_at, + pendingRemoval: rawGroupItem.marked_for_deletion_at, }; } diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 2e7bf9a7d5a..dd69e2d6f1f 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -17,10 +17,18 @@ export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DAT export const discardAllChanges = ({ state, commit, dispatch }) => { state.changedFiles.forEach(file => { - commit(types.DISCARD_FILE_CHANGES, file.path); + if (file.tempFile || file.prevPath) dispatch('closeFile', file); if (file.tempFile) { - dispatch('closeFile', file); + dispatch('deleteEntry', file.path); + } else if (file.prevPath) { + dispatch('renameEntry', { + path: file.path, + name: file.prevName, + parentPath: file.prevParentPath, + }); + } else { + commit(types.DISCARD_FILE_CHANGES, file.path); } }); diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 2a9321f6733..c1ca5449ba3 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -20,8 +20,10 @@ import invalidUrl from '~/lib/utils/invalid_url'; import DateTimePicker from './date_time_picker/date_time_picker.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; +import GroupEmptyState from './group_empty_state.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import { getTimeDiff, isValidDate, getAddMetricTrackingOptions } from '../utils'; +import { metricStates } from '../constants'; export default { components: { @@ -29,6 +31,7 @@ export default { PanelType, GraphGroup, EmptyState, + GroupEmptyState, Icon, GlButton, GlDropdown, @@ -184,7 +187,7 @@ export default { 'allDashboards', 'additionalPanelTypesEnabled', ]), - ...mapGetters('monitoringDashboard', ['metricsWithData']), + ...mapGetters('monitoringDashboard', ['getMetricStates']), firstDashboard() { return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0 ? this.allDashboards[0] @@ -284,12 +287,35 @@ export default { submitCustomMetricsForm() { this.$refs.customMetricsForm.submit(); }, - groupHasData(group) { - return this.metricsWithData(group.key).length > 0; - }, onDateTimePickerApply(timeWindowUrlParams) { return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href)); }, + /** + * Return a single empty state for a group. + * + * If all states are the same a single state is returned to be displayed + * Except if the state is OK, in which case the group is displayed. + * + * @param {String} groupKey - Identifier for group + * @returns {String} state code from `metricStates` + */ + groupSingleEmptyState(groupKey) { + const states = this.getMetricStates(groupKey); + if (states.length === 1 && states[0] !== metricStates.OK) { + return states[0]; + } + return null; + }, + /** + * A group should be not collapsed if any metric is loaded (OK) + * + * @param {String} groupKey - Identifier for group + * @returns {Boolean} If the group should be collapsed + */ + collapseGroup(groupKey) { + // Collapse group if no data is available + return !this.getMetricStates(groupKey).includes(metricStates.OK); + }, getAddMetricTrackingOptions, }, addMetric: { @@ -446,9 +472,9 @@ export default { :key="`${groupData.group}.${groupData.priority}`" :name="groupData.group" :show-panels="showPanels" - :collapse-group="!groupHasData(groupData)" + :collapse-group="collapseGroup(groupData.key)" > - <div v-if="groupHasData(groupData)"> + <div v-if="!groupSingleEmptyState(groupData.key)"> <vue-draggable :value="groupData.panels" group="metrics-dashboard" @@ -487,18 +513,12 @@ export default { </vue-draggable> </div> <div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6"> - <empty-state + <group-empty-state ref="empty-group" - selected-state="noDataGroup" :documentation-path="documentationPath" :settings-path="settingsPath" - :clusters-path="clustersPath" - :empty-getting-started-svg-path="emptyGettingStartedSvgPath" - :empty-loading-svg-path="emptyLoadingSvgPath" - :empty-no-data-svg-path="emptyNoDataSvgPath" - :empty-no-data-small-svg-path="emptyNoDataSmallSvgPath" - :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath" - :compact="true" + :selected-state="groupSingleEmptyState(groupData.key)" + :svg-path="emptyNoDataSmallSvgPath" /> </div> </graph-group> diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index 728910dd633..d3157b731b2 100644 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -84,11 +84,6 @@ export default { secondaryButtonText: '', secondaryButtonPath: '', }, - noDataGroup: { - svgUrl: this.emptyNoDataSmallSvgPath, - title: __('No data to display'), - description: __('The data source is connected, but there is no data to display.'), - }, unableToConnect: { svgUrl: this.emptyUnableToConnectSvgPath, title: __('Unable to connect to Prometheus server'), diff --git a/app/assets/javascripts/monitoring/components/group_empty_state.vue b/app/assets/javascripts/monitoring/components/group_empty_state.vue new file mode 100644 index 00000000000..dee4e5998ee --- /dev/null +++ b/app/assets/javascripts/monitoring/components/group_empty_state.vue @@ -0,0 +1,105 @@ +<script> +import { __, sprintf } from '~/locale'; +import { GlEmptyState } from '@gitlab/ui'; +import { metricStates } from '../constants'; + +export default { + components: { + GlEmptyState, + }, + props: { + documentationPath: { + type: String, + required: true, + }, + settingsPath: { + type: String, + required: true, + }, + selectedState: { + type: String, + required: true, + }, + svgPath: { + type: String, + required: true, + }, + }, + data() { + const documentationLink = `<a href="${this.documentationPath}">${__('More information')}</a>`; + return { + states: { + [metricStates.NO_DATA]: { + title: __('No data to display'), + slottedDescription: sprintf( + __( + 'The data source is connected, but there is no data to display. %{documentationLink}', + ), + { documentationLink }, + false, + ), + }, + [metricStates.TIMEOUT]: { + title: __('Connection timed out'), + slottedDescription: sprintf( + __( + "Charts can't be displayed as the request for data has timed out. %{documentationLink}", + ), + { documentationLink }, + false, + ), + }, + [metricStates.CONNECTION_FAILED]: { + title: __('Connection failed'), + description: __(`We couldn't reach the Prometheus server. + Either the server no longer exists or the configuration details need updating.`), + buttonText: __('Verify configuration'), + buttonPath: this.settingsPath, + }, + [metricStates.BAD_QUERY]: { + title: __('Query cannot be processed'), + slottedDescription: sprintf( + __( + `The Prometheus server responded with "bad request". + Please check your queries are correct and are supported in your Prometheus version. %{documentationLink}`, + ), + { documentationLink }, + false, + ), + buttonText: __('Verify configuration'), + buttonPath: this.settingsPath, + }, + [metricStates.LOADING]: { + title: __('Waiting for performance data'), + description: __(`Creating graphs uses the data from the Prometheus server. + If this takes a long time, ensure that data is available.`), + }, + [metricStates.UNKNOWN_ERROR]: { + title: __('An error has occurred'), + description: __('An error occurred while loading the data. Please try again.'), + }, + }, + }; + }, + computed: { + currentState() { + return this.states[this.selectedState] || this.states[metricStates.UNKNOWN_ERROR]; + }, + }, +}; +</script> + +<template> + <gl-empty-state + :title="currentState.title" + :primary-button-text="currentState.buttonText" + :primary-button-link="currentState.buttonPath" + :description="currentState.description" + :svg-path="svgPath" + :compact="true" + > + <template v-if="currentState.slottedDescription" #description> + <div v-html="currentState.slottedDescription"></div> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index e613351e524..398b45b9012 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -3,9 +3,19 @@ import { __ } from '~/locale'; export const PROMETHEUS_TIMEOUT = 120000; // TWO_MINUTES /** - * Errors in Prometheus Queries (PromQL) for metrics + * States and error states in Prometheus Queries (PromQL) for metrics */ -export const metricsErrors = { +export const metricStates = { + /** + * Metric data is available + */ + OK: 'OK', + + /** + * Metric data is being fetched + */ + LOADING: 'LOADING', + /** * Connection timed out to prometheus server * the timeout is set to PROMETHEUS_TIMEOUT @@ -24,12 +34,12 @@ export const metricsErrors = { CONNECTION_FAILED: 'CONNECTION_FAILED', /** - * The prometheus server was reach but it cannot process + * The prometheus server was reached but it cannot process * the query. This can happen for several reasons: * - PromQL syntax is incorrect * - An operator is not supported */ - BAD_DATA: 'BAD_DATA', + BAD_QUERY: 'BAD_QUERY', /** * No specific reason found for error diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index a655191b2b4..1cb82ce0083 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -132,7 +132,7 @@ export const fetchPrometheusMetric = ({ commit }, { metric, params }) => { commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metric_id, result }); }) .catch(error => { - commit(types.RECEIVE_METRIC_RESULT_ERROR, { metricId: metric.metric_id, error }); + commit(types.RECEIVE_METRIC_RESULT_FAILURE, { metricId: metric.metric_id, error }); // Continue to throw error so the dashboard can notify using createFlash throw error; }); diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index 3eddd52705d..a13157c6f87 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -2,6 +2,36 @@ const metricsIdsInPanel = panel => panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId); /** + * Get all state for metric in the dashboard or a group. The + * states are not repeated so the dashboard or group can show + * a global state. + * + * @param {Object} state + * @returns {Function} A function that returns an array of + * states in all the metric in the dashboard or group. + */ +export const getMetricStates = state => groupKey => { + let groups = state.dashboard.panel_groups; + if (groupKey) { + groups = groups.filter(group => group.key === groupKey); + } + + const metricStates = groups.reduce((acc, group) => { + group.panels.forEach(panel => { + panel.metrics.forEach(metric => { + if (metric.state) { + acc.push(metric.state); + } + }); + }); + return acc; + }, []); + + // Deduplicate and sort array + return Array.from(new Set(metricStates)).sort(); +}; + +/** * Getter to obtain the list of metric ids that have data * * Useful to understand which parts of the dashboard should diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index e4e467f3d68..74068e1d846 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -12,7 +12,7 @@ export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAIL export const REQUEST_METRIC_RESULT = 'REQUEST_METRIC_RESULT'; export const RECEIVE_METRIC_RESULT_SUCCESS = 'RECEIVE_METRIC_RESULT_SUCCESS'; -export const RECEIVE_METRIC_RESULT_ERROR = 'RECEIVE_METRIC_RESULT_ERROR'; +export const RECEIVE_METRIC_RESULT_FAILURE = 'RECEIVE_METRIC_RESULT_FAILURE'; export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index f04c12c2ac8..16a34a6c026 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -3,7 +3,7 @@ import { slugify } from '~/lib/utils/text_utility'; import * as types from './mutation_types'; import { normalizeMetric, normalizeQueryResult } from './utils'; import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; -import { metricsErrors } from '../constants'; +import { metricStates } from '../constants'; import httpStatusCodes from '~/lib/utils/http_status'; const normalizePanelMetrics = (metrics, defaultLabel) => @@ -41,39 +41,39 @@ const findMetricInDashboard = (metricId, dashboard) => { * @param {Object} metric - Metric object as defined in the dashboard * @param {Object} state - New state * @param {Array|null} state.result - Array of results - * @param {String} state.error - Error code from metricsErrors + * @param {String} state.error - Error code from metricStates * @param {Boolean} state.loading - True if the metric is loading */ -const setMetricState = (metric, { result = null, error = null, loading = false }) => { +const setMetricState = (metric, { result = null, loading = false, state = null }) => { Vue.set(metric, 'result', result); - Vue.set(metric, 'error', error); Vue.set(metric, 'loading', loading); + Vue.set(metric, 'state', state); }; /** - * Maps a backened error state to a `metricsErrors` constant + * Maps a backened error state to a `metricStates` constant * @param {Object} error - Error from backend response */ -const getMetricError = error => { +const emptyStateFromError = error => { if (!error) { - return metricsErrors.UNKNOWN_ERROR; + return metricStates.UNKNOWN_ERROR; } // Special error responses if (error.message === BACKOFF_TIMEOUT) { - return metricsErrors.TIMEOUT; + return metricStates.TIMEOUT; } // Axios error responses const { response } = error; if (response && response.status === httpStatusCodes.SERVICE_UNAVAILABLE) { - return metricsErrors.CONNECTION_FAILED; + return metricStates.CONNECTION_FAILED; } else if (response && response.status === httpStatusCodes.BAD_REQUEST) { // Note: "error.response.data.error" may contain Prometheus error information - return metricsErrors.BAD_DATA; + return metricStates.BAD_QUERY; } - return metricsErrors.UNKNOWN_ERROR; + return metricStates.UNKNOWN_ERROR; }; export default { @@ -132,9 +132,9 @@ export default { */ [types.REQUEST_METRIC_RESULT](state, { metricId }) { const metric = findMetricInDashboard(metricId, state.dashboard); - setMetricState(metric, { loading: true, + state: metricStates.LOADING, }); }, [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, result }) { @@ -146,24 +146,24 @@ export default { const metric = findMetricInDashboard(metricId, state.dashboard); if (!result || result.length === 0) { - // If no data is return we still consider it an error and set it to undefined setMetricState(metric, { - error: metricsErrors.NO_DATA, + state: metricStates.NO_DATA, }); } else { const normalizedResults = result.map(normalizeQueryResult); setMetricState(metric, { result: Object.freeze(normalizedResults), + state: metricStates.OK, }); } }, - [types.RECEIVE_METRIC_RESULT_ERROR](state, { metricId, error }) { + [types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) { if (!metricId) { return; } const metric = findMetricInDashboard(metricId, state.dashboard); setMetricState(metric, { - error: getMetricError(error), + state: emptyStateFromError(error), }); }, diff --git a/app/controllers/projects/hook_logs_controller.rb b/app/controllers/projects/hook_logs_controller.rb index a7afc3d77a5..ed7e7b68acb 100644 --- a/app/controllers/projects/hook_logs_controller.rb +++ b/app/controllers/projects/hook_logs_controller.rb @@ -16,15 +16,17 @@ class Projects::HookLogsController < Projects::ApplicationController end def retry - result = hook.execute(hook_log.request_data, hook_log.trigger) - - set_hook_execution_notice(result) - + execute_hook redirect_to edit_project_hook_path(@project, @hook) end private + def execute_hook + result = hook.execute(hook_log.request_data, hook_log.trigger) + set_hook_execution_notice(result) + end + def hook @hook ||= @project.hooks.find(params[:hook_id]) end diff --git a/app/controllers/projects/service_hook_logs_controller.rb b/app/controllers/projects/service_hook_logs_controller.rb new file mode 100644 index 00000000000..5c814ea139f --- /dev/null +++ b/app/controllers/projects/service_hook_logs_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class Projects::ServiceHookLogsController < Projects::HookLogsController + before_action :service, only: [:show, :retry] + + def retry + execute_hook + redirect_to edit_project_service_path(@project, @service) + end + + private + + def hook + @hook ||= service.service_hook + end + + def service + @service ||= @project.find_or_initialize_service(params[:service_id]) + end +end diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index c9f680a4696..daaca9e1268 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -7,6 +7,7 @@ class Projects::ServicesController < Projects::ApplicationController before_action :authorize_admin_project! before_action :ensure_service_enabled before_action :service + before_action :web_hook_logs, only: [:edit, :update] respond_to :html @@ -77,6 +78,12 @@ class Projects::ServicesController < Projects::ApplicationController @service ||= @project.find_or_initialize_service(params[:id]) end + def web_hook_logs + return unless @service.service_hook.present? + + @web_hook_logs ||= @service.service_hook.web_hook_logs.recent.page(params[:page]) + end + def ensure_service_enabled render_404 unless service end diff --git a/app/graphql/mutations/snippets/mark_as_spam.rb b/app/graphql/mutations/snippets/mark_as_spam.rb new file mode 100644 index 00000000000..260a9753f76 --- /dev/null +++ b/app/graphql/mutations/snippets/mark_as_spam.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Mutations + module Snippets + class MarkAsSpam < Base + graphql_name 'MarkAsSpamSnippet' + + argument :id, + GraphQL::ID_TYPE, + required: true, + description: 'The global id of the snippet to update' + + def resolve(id:) + snippet = authorized_find!(id: id) + + result = mark_as_spam(snippet) + errors = result ? [] : ['Error with Akismet. Please check the logs for more info.'] + + { + errors: errors + } + end + + private + + def mark_as_spam(snippet) + SpamService.new(snippet).mark_as_spam! + end + + def authorized_resource?(snippet) + super && snippet.submittable_as_spam_by?(context[:current_user]) + end + + def ability_name + "admin" + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 998dfdc7815..0a9c0143945 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -28,6 +28,7 @@ module Types mount_mutation Mutations::Snippets::Destroy mount_mutation Mutations::Snippets::Update mount_mutation Mutations::Snippets::Create + mount_mutation Mutations::Snippets::MarkAsSpam end end diff --git a/app/models/blob.rb b/app/models/blob.rb index c0f26ee64f8..0a425f2b961 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -4,6 +4,7 @@ class Blob < SimpleDelegator include Presentable include BlobLanguageFromGitAttributes + include BlobActiveModel CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour diff --git a/app/models/concerns/blob_active_model.rb b/app/models/concerns/blob_active_model.rb new file mode 100644 index 00000000000..89157e90e34 --- /dev/null +++ b/app/models/concerns/blob_active_model.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# To be included in blob classes which are to be +# treated as ActiveModel. +# +# The blob class must respond_to `project` +module BlobActiveModel + extend ActiveSupport::Concern + + class_methods do + def declarative_policy_class + 'BlobPolicy' + end + end + + def to_ability_name + 'blob' + end +end diff --git a/app/models/concerns/safe_url.rb b/app/models/concerns/safe_url.rb new file mode 100644 index 00000000000..febca7d241f --- /dev/null +++ b/app/models/concerns/safe_url.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SafeUrl + extend ActiveSupport::Concern + + def safe_url(usernames_whitelist: []) + return if url.nil? + + uri = URI.parse(url) + uri.password = '*****' if uri.password + uri.user = '*****' if uri.user && !usernames_whitelist.include?(uri.user) + uri.to_s + rescue URI::Error + end +end diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index 9ae697b9e59..a5f68831f34 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -2,6 +2,7 @@ class ProjectHook < WebHook include TriggerableHooks + include Presentable triggerable_hooks [ :push_hooks, diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb index 8f305dd7c22..4caa45a13d4 100644 --- a/app/models/hooks/service_hook.rb +++ b/app/models/hooks/service_hook.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ServiceHook < WebHook + include Presentable + belongs_to :service validates :service, presence: true diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb index cfb1f3ec63b..df0e7b30f84 100644 --- a/app/models/hooks/web_hook_log.rb +++ b/app/models/hooks/web_hook_log.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class WebHookLog < ApplicationRecord + include SafeUrl + include Presentable + belongs_to :web_hook serialize :request_headers, Hash # rubocop:disable Cop/ActiveRecordSerialize @@ -9,6 +12,8 @@ class WebHookLog < ApplicationRecord validates :web_hook, presence: true + before_save :obfuscate_basic_auth + def self.recent where('created_at >= ?', 2.days.ago.beginning_of_day) .order(created_at: :desc) @@ -17,4 +22,10 @@ class WebHookLog < ApplicationRecord def success? response_status =~ /^2/ end + + private + + def obfuscate_basic_auth + self.url = safe_url + end end diff --git a/app/models/readme_blob.rb b/app/models/readme_blob.rb index 7b49fa632f6..695b4e3ffe3 100644 --- a/app/models/readme_blob.rb +++ b/app/models/readme_blob.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ReadmeBlob < SimpleDelegator + include BlobActiveModel + attr_reader :repository def initialize(blob, repository) diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index c165a1a9b0d..1e5c93cd913 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -3,6 +3,7 @@ class RemoteMirror < ApplicationRecord include AfterCommitQueue include MirrorAuthentication + include SafeUrl MAX_FIRST_RUNTIME = 3.hours MAX_INCREMENTAL_RUNTIME = 1.hour @@ -194,13 +195,7 @@ class RemoteMirror < ApplicationRecord end def safe_url - return if url.nil? - - result = URI.parse(url) - result.password = '*****' if result.password - result.user = '*****' if result.user && result.user != 'git' # tokens or other data may be saved as user - result.to_s - rescue URI::Error + super(usernames_whitelist: %w[git]) end def ensure_remote! diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index f9c562364cb..c6867e48cbf 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -274,6 +274,10 @@ class WikiPage @attributes.merge!(attrs) end + def to_ability_name + 'wiki_page' + end + private # Process and format the title based on the user input. diff --git a/app/policies/blob_policy.rb b/app/policies/blob_policy.rb new file mode 100644 index 00000000000..639b9dfeea7 --- /dev/null +++ b/app/policies/blob_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class BlobPolicy < BasePolicy + delegate { @subject.project } + + rule { can?(:download_code) }.enable :read_blob +end diff --git a/app/policies/wiki_page_policy.rb b/app/policies/wiki_page_policy.rb new file mode 100644 index 00000000000..468632c9085 --- /dev/null +++ b/app/policies/wiki_page_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class WikiPagePolicy < BasePolicy + delegate { @subject.wiki.project } + + rule { can?(:read_wiki) }.enable :read_wiki_page +end diff --git a/app/presenters/hooks/project_hook_presenter.rb b/app/presenters/hooks/project_hook_presenter.rb new file mode 100644 index 00000000000..a65c7221b5a --- /dev/null +++ b/app/presenters/hooks/project_hook_presenter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ProjectHookPresenter < Gitlab::View::Presenter::Delegated + presents :project_hook + + def logs_details_path(log) + project_hook_hook_log_path(project, self, log) + end + + def logs_retry_path(log) + retry_project_hook_hook_log_path(project, self, log) + end +end diff --git a/app/presenters/hooks/service_hook_presenter.rb b/app/presenters/hooks/service_hook_presenter.rb new file mode 100644 index 00000000000..bc20d5b1a3b --- /dev/null +++ b/app/presenters/hooks/service_hook_presenter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ServiceHookPresenter < Gitlab::View::Presenter::Delegated + presents :service_hook + + def logs_details_path(log) + project_service_hook_log_path(service.project, service, log) + end + + def logs_retry_path(log) + retry_project_service_hook_log_path(service.project, service, log) + end +end diff --git a/app/presenters/web_hook_log_presenter.rb b/app/presenters/web_hook_log_presenter.rb new file mode 100644 index 00000000000..fca03ddb5d7 --- /dev/null +++ b/app/presenters/web_hook_log_presenter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class WebHookLogPresenter < Gitlab::View::Presenter::Delegated + presents :web_hook_log + + def details_path + web_hook.present.logs_details_path(self) + end + + def retry_path + web_hook.present.logs_retry_path(self) + end +end diff --git a/app/serializers/group_child_entity.rb b/app/serializers/group_child_entity.rb index 20d7032c970..a7fe4d3f9b9 100644 --- a/app/serializers/group_child_entity.rb +++ b/app/serializers/group_child_entity.rb @@ -99,3 +99,5 @@ class GroupChildEntity < Grape::Entity end end end + +GroupChildEntity.prepend_if_ee('EE::GroupChildEntity') diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 8c294218708..87edac36e33 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -92,9 +92,6 @@ class WebHookService end def log_execution(trigger:, url:, request_data:, response:, execution_duration:, error_message: nil) - # logging for ServiceHook's is not available - return if hook.is_a?(ServiceHook) - WebHookLog.create( web_hook: hook, trigger: trigger, diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index be5f1f4f9a8..ae90ffd9efc 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -9,6 +9,7 @@ = f.label s_('ProjectCreationLevel|Default project creation protection'), class: 'label-bold' = f.select :default_project_creation, options_for_select(Gitlab::Access.project_creation_options, @application_setting.default_project_creation), {}, class: 'form-control' = render_if_exists 'admin/application_settings/default_project_deletion_protection_setting', form: f + = render_if_exists 'admin/application_settings/default_project_deletion_adjourned_period_setting', form: f .form-group.visibility-level-setting = f.label :default_project_visibility, class: 'label-bold' = render('shared/visibility_radios', model_method: :default_project_visibility, form: f, selected_level: @application_setting.default_project_visibility, form_model: Project.new) @@ -53,6 +54,7 @@ = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') %span.form-text.text-muted#clone-protocol-help = _('Allow only the selected protocols to be used for Git access.') + .form-group = f.label :custom_http_clone_url_root, _('Custom Git clone URL for HTTP(S)'), class: 'label-bold' = f.text_field :custom_http_clone_url_root, class: 'form-control', placeholder: 'https://git.example.com', :'aria-describedby' => 'custom_http_clone_url_root_help_block' diff --git a/app/views/admin/projects/_archived.html.haml b/app/views/admin/projects/_archived.html.haml new file mode 100644 index 00000000000..8b4d5806c47 --- /dev/null +++ b/app/views/admin/projects/_archived.html.haml @@ -0,0 +1,3 @@ +- if project.archived + %span.badge.badge-warning + = _('archived') diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml index 2f7ad35eb3e..f842ab2d009 100644 --- a/app/views/admin/projects/_projects.html.haml +++ b/app/views/admin/projects/_projects.html.haml @@ -14,8 +14,7 @@ .stats %span.badge.badge-pill = storage_counter(project.statistics&.storage_size) - - if project.archived - %span.badge.badge-warning archived + = render_if_exists 'admin/projects/archived', project: project .title = link_to(admin_project_path(project)) do .dash-project-avatar diff --git a/app/views/projects/_archived_notice.html.haml b/app/views/projects/_archived_notice.html.haml new file mode 100644 index 00000000000..522693ae24a --- /dev/null +++ b/app/views/projects/_archived_notice.html.haml @@ -0,0 +1,5 @@ +- if project.archived? + .text-warning.center.prepend-top-20 + %p + = icon("exclamation-triangle fw") + = _('Archived project! Repository and other project resources are read only') diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml new file mode 100644 index 00000000000..6c84fbfeeb3 --- /dev/null +++ b/app/views/projects/_remove.html.haml @@ -0,0 +1,10 @@ +- return unless can?(current_user, :remove_project, project) + +.sub-section + %h4.danger-title= _('Remove project') + %p + %strong= _('Removing the project will delete its repository and all related resources including issues, merge requests etc.') + = form_tag(project_path(project), method: :delete) do + %p + %strong= _('Removed projects cannot be restored!') + = button_to _('Remove project'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(project) } diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 7ad52673137..1c18487f688 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -73,23 +73,7 @@ = render 'export', project: @project - - if can? current_user, :archive_project, @project - .sub-section - %h4.warning-title - - if @project.archived? - = _('Unarchive project') - - else - = _('Archive project') - - if @project.archived? - %p= _("Unarchiving the project will restore people's ability to make changes to it. The repository can be committed to, and issues, comments and other entities can be created. <strong>Once active this project shows up in the search and on the dashboard.</strong>").html_safe - = link_to _('Unarchive project'), unarchive_project_path(@project), - data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' }, - method: :post, class: "btn btn-success" - - else - %p= _("Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches. <strong>The repository cannot be committed to, and no issues, comments or other entities can be created.</strong>").html_safe - = link_to _('Archive project'), archive_project_path(@project), - data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' }, - method: :post, class: "btn btn-warning" + = render_if_exists 'projects/settings/archive' .sub-section.rename-repository %h4.warning-title= _('Change path') = render 'projects/errors' @@ -135,14 +119,7 @@ %strong= _('Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source.') = button_to _('Remove fork relationship'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_warning_message(@project) } - - if can?(current_user, :remove_project, @project) - .sub-section - %h4.danger-title= _('Remove project') - %p= _('Removing the project will delete its repository and all related resources including issues, merge requests etc.') - = form_tag(project_path(@project), method: :delete) do - %p - %strong= _('Removed projects cannot be restored!') - = button_to _('Remove project'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(@project) } + = render 'remove', project: @project .save-project-loader.hide .center diff --git a/app/views/projects/hook_logs/_index.html.haml b/app/views/projects/hook_logs/_index.html.haml index 3e54c3ca9f8..ada986dd969 100644 --- a/app/views/projects/hook_logs/_index.html.haml +++ b/app/views/projects/hook_logs/_index.html.haml @@ -28,7 +28,7 @@ %td.light = time_ago_with_tooltip(hook_log.created_at) %td - = link_to 'View details', project_hook_hook_log_path(project, hook, hook_log) + = link_to 'View details', hook_log.present.details_path = paginate hook_logs, theme: 'gitlab' diff --git a/app/views/projects/hook_logs/show.html.haml b/app/views/projects/hook_logs/show.html.haml index bd8ca5e7d70..a8796cd7b1c 100644 --- a/app/views/projects/hook_logs/show.html.haml +++ b/app/views/projects/hook_logs/show.html.haml @@ -3,7 +3,6 @@ %h4.prepend-top-0 Request details .col-lg-9 - - = link_to 'Resend Request', retry_project_hook_hook_log_path(@project, @hook, @hook_log), method: :post, class: "btn btn-default float-right prepend-left-10" + = link_to 'Resend Request', @hook_log.present.retry_path, method: :post, class: "btn btn-default float-right prepend-left-10" = render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log } diff --git a/app/views/projects/services/edit.html.haml b/app/views/projects/services/edit.html.haml index 1e7903535c6..e3e8a312431 100644 --- a/app/views/projects/services/edit.html.haml +++ b/app/views/projects/services/edit.html.haml @@ -1,8 +1,10 @@ - breadcrumb_title @service.title - page_title @service.title, s_("ProjectService|Services") - add_to_breadcrumbs(s_("ProjectService|Settings"), edit_project_path(@project)) -- add_to_breadcrumbs(s_("ProjectService|Integrations"), namespace_project_settings_integrations_path) +- add_to_breadcrumbs(s_("ProjectService|Integrations"), project_settings_integrations_path(@project)) = render 'deprecated_message' if @service.deprecation_message = render 'form' +- if @web_hook_logs + = render partial: 'projects/hook_logs/index', locals: { hook: @service.service_hook, hook_logs: @web_hook_logs, project: @project } diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml new file mode 100644 index 00000000000..3307c3775ec --- /dev/null +++ b/app/views/projects/settings/_archive.html.haml @@ -0,0 +1,18 @@ +- return unless can?(current_user, :archive_project, @project) + +.sub-section + %h4.warning-title + - if @project.archived? + = _('Unarchive project') + - else + = _('Archive project') + - if @project.archived? + %p= _("Unarchiving the project will restore people's ability to make changes to it. The repository can be committed to, and issues, comments, and other entities can be created. %{strong_start}Once active, this project shows up in the search and on the dashboard.%{strong_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + = link_to _('Unarchive project'), unarchive_project_path(@project), + data: { confirm: _("Are you sure that you want to unarchive this project?"), qa_selector: 'unarchive_project_link' }, + method: :post, class: "btn btn-success" + - else + %p= _("Archiving the project will make it entirely read only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + = link_to _('Archive project'), archive_project_path(@project), + data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' }, + method: :post, class: "btn btn-warning" diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index c5653c3dd5a..8f13806e8cd 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -18,11 +18,8 @@ - if can?(current_user, :download_code, @project) && @project.repository_languages.present? = repository_languages_bar(@project.repository_languages) - - if @project.archived? - .text-warning.center.prepend-top-20 - %p - = icon("exclamation-triangle fw") - #{ _('Archived project! Repository and other project resources are read-only') } + = render "archived_notice", project: @project + = render_if_exists "projects/marked_for_deletion_notice", project: @project - view_path = @project.default_view diff --git a/app/views/shared/projects/_archived.html.haml b/app/views/shared/projects/_archived.html.haml new file mode 100644 index 00000000000..fad93d14390 --- /dev/null +++ b/app/views/shared/projects/_archived.html.haml @@ -0,0 +1,3 @@ +- if project.archived + %span.d-flex.badge.badge-warning + = _('archived') diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 5b9af0267cc..45e95685677 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -67,8 +67,7 @@ %span.icon-wrapper.pipeline-status = render 'ci/status/icon', status: project.last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path - - if project.archived - %span.d-flex.icon-wrapper.badge.badge-warning archived + = render_if_exists 'shared/projects/archived', project: project - if stars = link_to project_starrers_path(project), class: "d-flex align-items-center icon-wrapper stars has-tooltip", diff --git a/changelogs/unreleased/27244-discard-all-changes.yml b/changelogs/unreleased/27244-discard-all-changes.yml new file mode 100644 index 00000000000..70750208667 --- /dev/null +++ b/changelogs/unreleased/27244-discard-all-changes.yml @@ -0,0 +1,5 @@ +--- +title: Fix "Discard all" for new and renamed files +merge_request: 21854 +author: +type: fixed diff --git a/changelogs/unreleased/34121-add-error-states-to-getters.yml b/changelogs/unreleased/34121-add-error-states-to-getters.yml new file mode 100644 index 00000000000..9e9bd93d509 --- /dev/null +++ b/changelogs/unreleased/34121-add-error-states-to-getters.yml @@ -0,0 +1,5 @@ +--- +title: Add specific error states to dashboard +merge_request: 21618 +author: +type: added diff --git a/changelogs/unreleased/fj-add-mark-as-spam-snippet-mutation.yml b/changelogs/unreleased/fj-add-mark-as-spam-snippet-mutation.yml new file mode 100644 index 00000000000..b5f492e6628 --- /dev/null +++ b/changelogs/unreleased/fj-add-mark-as-spam-snippet-mutation.yml @@ -0,0 +1,5 @@ +--- +title: Add mark as spam snippet mutation +merge_request: 21912 +author: +type: other diff --git a/changelogs/unreleased/log_service_web_hooks.yml b/changelogs/unreleased/log_service_web_hooks.yml new file mode 100644 index 00000000000..b0a5772da22 --- /dev/null +++ b/changelogs/unreleased/log_service_web_hooks.yml @@ -0,0 +1,5 @@ +--- +title: Added WebHookLogs for ServiceHooks +merge_request: 20976 +author: +type: added diff --git a/changelogs/unreleased/update_auto_deploy_image.yml b/changelogs/unreleased/update_auto_deploy_image.yml new file mode 100644 index 00000000000..382369e47bb --- /dev/null +++ b/changelogs/unreleased/update_auto_deploy_image.yml @@ -0,0 +1,5 @@ +--- +title: Update auto-deploy-image to v0.8.3 +merge_request: 21696 +author: +type: fixed diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 8e4aa5701b4..691e4339bf0 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -475,6 +475,9 @@ Gitlab.ee do Settings.cron_jobs['clear_shared_runners_minutes_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['clear_shared_runners_minutes_worker']['cron'] ||= '0 0 1 * *' Settings.cron_jobs['clear_shared_runners_minutes_worker']['job_class'] = 'ClearSharedRunnersMinutesWorker' + Settings.cron_jobs['adjourned_projects_deletion_cron_worker'] ||= Settingslogic.new({}) + Settings.cron_jobs['adjourned_projects_deletion_cron_worker']['cron'] ||= '0 4 * * *' + Settings.cron_jobs['adjourned_projects_deletion_cron_worker']['job_class'] = 'AdjournedProjectsDeletionCronWorker' Settings.cron_jobs['geo_file_download_dispatch_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['geo_file_download_dispatch_worker']['cron'] ||= '*/1 * * * *' Settings.cron_jobs['geo_file_download_dispatch_worker']['job_class'] ||= 'Geo::FileDownloadDispatchWorker' diff --git a/config/routes/project.rb b/config/routes/project.rb index ea406d17bef..d2abc73f7cc 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -159,6 +159,12 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do member do put :test end + + resources :hook_logs, only: [:show], controller: :service_hook_logs do + member do + post :retry + end + end end resources :boards, only: [:index, :show, :create, :update, :destroy], constraints: { id: /\d+/ } do diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 063a3cc2b5b..68ad819d48b 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -124,4 +124,4 @@ - [design_management_new_version, 1] - [epics, 2] - [personal_access_tokens, 1] - + - [adjourned_project_deletion, 1] diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md index 80fa30da357..57048059476 100644 --- a/doc/administration/monitoring/prometheus/gitlab_metrics.md +++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md @@ -90,7 +90,6 @@ The following metrics can be controlled by feature flags: | Metric | Feature Flag | |:---------------------------------------------------------------|:-------------------------------------------------------------------| | `gitlab_method_call_duration_seconds` | `prometheus_metrics_method_instrumentation` | -| `gitlab_transaction_allocated_memory_bytes` | `prometheus_metrics_transaction_allocated_memory` | | `gitlab_view_rendering_duration_seconds` | `prometheus_metrics_view_instrumentation` | ## Sidekiq Metrics available for Geo **(PREMIUM)** diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 484841fd712..4673356cf9d 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -3069,6 +3069,41 @@ type LabelEdge { node: Label } +""" +Autogenerated input type of MarkAsSpamSnippet +""" +input MarkAsSpamSnippetInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The global id of the snippet to update + """ + id: ID! +} + +""" +Autogenerated return type of MarkAsSpamSnippet +""" +type MarkAsSpamSnippetPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Reasons why the mutation failed. + """ + errors: [String!]! + + """ + The snippet after mutation + """ + snippet: Snippet +} + type MergeRequest implements Noteable { """ Indicates if members of the target project can push to the fork @@ -3941,6 +3976,7 @@ type Mutation { issueSetConfidential(input: IssueSetConfidentialInput!): IssueSetConfidentialPayload issueSetDueDate(input: IssueSetDueDateInput!): IssueSetDueDatePayload issueSetWeight(input: IssueSetWeightInput!): IssueSetWeightPayload + markAsSpamSnippet(input: MarkAsSpamSnippetInput!): MarkAsSpamSnippetPayload mergeRequestSetAssignees(input: MergeRequestSetAssigneesInput!): MergeRequestSetAssigneesPayload mergeRequestSetLabels(input: MergeRequestSetLabelsInput!): MergeRequestSetLabelsPayload mergeRequestSetLocked(input: MergeRequestSetLockedInput!): MergeRequestSetLockedPayload diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index f3437a26f42..398ae52c130 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -16122,6 +16122,33 @@ "deprecationReason": null }, { + "name": "markAsSpamSnippet", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MarkAsSpamSnippetInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MarkAsSpamSnippetPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "mergeRequestSetAssignees", "description": null, "args": [ @@ -19664,6 +19691,108 @@ }, { "kind": "OBJECT", + "name": "MarkAsSpamSnippetPayload", + "description": "Autogenerated return type of MarkAsSpamSnippet", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Reasons why the mutation failed.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "snippet", + "description": "The snippet after mutation", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "Snippet", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "MarkAsSpamSnippetInput", + "description": "Autogenerated input type of MarkAsSpamSnippet", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": "The global id of the snippet to update", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", "name": "DesignManagementUploadPayload", "description": "Autogenerated return type of DesignManagementUpload", "fields": [ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 1371daa6453..9fb39322f5c 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -429,6 +429,14 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph | `color` | String! | Background color of the label | | `textColor` | String! | Text color of the label | +### MarkAsSpamSnippetPayload + +| Name | Type | Description | +| --- | ---- | ---------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Reasons why the mutation failed. | +| `snippet` | Snippet | The snippet after mutation | + ### MergeRequest | Name | Type | Description | diff --git a/doc/api/projects.md b/doc/api/projects.md index b49fac8d2c9..209d41d62cd 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -1713,7 +1713,12 @@ Example response: ## Remove project -Removes a project including all associated resources (issues, merge requests etc). +This endpoint either: + +- Removes a project including all associated resources (issues, merge requests etc). +- From GitLab 12.6 on Premium or higher tiers, marks a project for deletion. Actual + deletion happens after number of days specified in + [instance settings](../user/admin_area/settings/visibility_and_access_controls.md#project-deletion-adjourned-period-premium-only). ``` DELETE /projects/:id @@ -1723,6 +1728,18 @@ DELETE /projects/:id | --------- | ---- | -------- | ----------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | +## Restore project marked for deletion **(PREMIUM)** + +Restores project marked for deletion. + +``` +POST /projects/:id/restore +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) | + ## Upload a file Uploads a file to the specified project to be used in an issue or merge request description, or a comment. diff --git a/doc/api/settings.md b/doc/api/settings.md index 185cce6353e..fa0efcaa5f0 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -72,14 +72,15 @@ Example response: ``` Users on GitLab [Premium or Ultimate](https://about.gitlab.com/pricing/) may also see -the `file_template_project_id` or the `geo_node_allowed_ips` parameters: +the `file_template_project_id`, `deletion_adjourned_period`, or the `geo_node_allowed_ips` parameters: ```json { "id" : 1, "signup_enabled" : true, "file_template_project_id": 1, - "geo_node_allowed_ips": "0.0.0.0/0, ::/0" + "geo_node_allowed_ips": "0.0.0.0/0, ::/0", + "deletion_adjourned_period": 7, ... } ``` @@ -162,6 +163,7 @@ these parameters: - `file_template_project_id` - `geo_node_allowed_ips` - `geo_status_timeout` +- `deletion_adjourned_period` Example responses: **(PREMIUM ONLY)** @@ -292,6 +294,7 @@ are listed in the descriptions of the relevant settings. | `plantuml_enabled` | boolean | no | (**If enabled, requires:** `plantuml_url`) Enable PlantUML integration. Default is `false`. | | `plantuml_url` | string | required by: `plantuml_enabled` | The PlantUML instance URL for integration. | | `polling_interval_multiplier` | decimal | no | Interval multiplier used by endpoints that perform polling. Set to `0` to disable polling. | +| `deletion_adjourned_period` | integer | no | **(PREMIUM ONLY)** How many days after marking project for deletion it is actually removed. Value between 0 and 90. | `project_export_enabled` | boolean | no | Enable project export. | | `prometheus_metrics_enabled` | boolean | no | Enable Prometheus metrics. | | `protected_ci_variables` | boolean | no | Environment variables are protected by default. | diff --git a/doc/user/admin_area/settings/visibility_and_access_controls.md b/doc/user/admin_area/settings/visibility_and_access_controls.md index 95e4c45e56c..74398128593 100644 --- a/doc/user/admin_area/settings/visibility_and_access_controls.md +++ b/doc/user/admin_area/settings/visibility_and_access_controls.md @@ -48,6 +48,17 @@ To ensure only admin users can delete projects: 1. Check the **Default project deletion protection** checkbox. 1. Click **Save changes**. +## Project deletion adjourned period **(PREMIUM ONLY)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/32935) in GitLab 12.6. + +By default, project marked for deletion will be permanently removed after 7 days. This period may be changed. + +To change this period: + +1. Select the desired option. +1. Click **Save changes**. + ## Default project visibility To set the default visibility levels for new projects: diff --git a/lib/api/projects.rb b/lib/api/projects.rb index a1fce9e8b20..d1f99ea49ce 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -26,6 +26,14 @@ module API def verify_update_project_attrs!(project, attrs) end + + def delete_project(user_project) + destroy_conditionally!(user_project) do + ::Projects::DestroyService.new(user_project, current_user, {}).async_execute + end + + accepted! + end end helpers do @@ -404,11 +412,7 @@ module API delete ":id" do authorize! :remove_project, user_project - destroy_conditionally!(user_project) do - ::Projects::DestroyService.new(user_project, current_user, {}).async_execute - end - - accepted! + delete_project(user_project) end desc 'Mark this project as forked from another' diff --git a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml index cb45c12c2b0..d20d04425f6 100644 --- a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml @@ -1,5 +1,5 @@ .auto-deploy: - image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v0.8.0" + image: "registry.gitlab.com/gitlab-org/cluster-integration/auto-deploy-image:v0.8.3" review: extends: .auto-deploy diff --git a/lib/gitlab/metrics/transaction.rb b/lib/gitlab/metrics/transaction.rb index 115368c8bc6..552eae639e6 100644 --- a/lib/gitlab/metrics/transaction.rb +++ b/lib/gitlab/metrics/transaction.rb @@ -164,7 +164,6 @@ module Gitlab docstring 'Transaction allocated memory bytes' base_labels BASE_LABELS buckets [100, 1000, 10000, 100000, 1000000, 10000000] - with_feature :prometheus_metrics_transaction_allocated_memory end def self.transaction_metric(name, type, prefix: nil, tags: {}) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9fe8e8e670b..6301a72f48a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1720,6 +1720,9 @@ msgstr "" msgid "An error occurred while loading issues" msgstr "" +msgid "An error occurred while loading the data. Please try again." +msgstr "" + msgid "An error occurred while loading the file" msgstr "" @@ -2031,13 +2034,16 @@ msgstr "" msgid "Archive project" msgstr "" +msgid "Archived project! Repository and other project resources are read only" +msgstr "" + msgid "Archived project! Repository and other project resources are read-only" msgstr "" msgid "Archived projects" msgstr "" -msgid "Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches. <strong>The repository cannot be committed to, and no issues, comments or other entities can be created.</strong>" +msgid "Archiving the project will make it entirely read only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end}" msgstr "" msgid "Are you setting up GitLab for a company?" @@ -3148,6 +3154,9 @@ msgstr "" msgid "Charts" msgstr "" +msgid "Charts can't be displayed as the request for data has timed out. %{documentationLink}" +msgstr "" + msgid "Chat" msgstr "" @@ -4713,9 +4722,15 @@ msgstr "" msgid "Connecting..." msgstr "" +msgid "Connection failed" +msgstr "" + msgid "Connection failure" msgstr "" +msgid "Connection timed out" +msgstr "" + msgid "Contact an owner of group %{namespace_name} to upgrade the plan." msgstr "" @@ -5554,6 +5569,9 @@ msgstr "" msgid "Default classification label" msgstr "" +msgid "Default deletion adjourned period" +msgstr "" + msgid "Default description template for issues" msgstr "" @@ -5665,6 +5683,9 @@ msgstr "" msgid "Deleting the license failed. You are not permitted to perform this action." msgstr "" +msgid "Deletion pending. This project will be removed on %{date}. Repository and other project resources are read-only." +msgstr "" + msgid "Denied authorization of chat nickname %{user_name}." msgstr "" @@ -9339,6 +9360,9 @@ msgstr "" msgid "How it works" msgstr "" +msgid "How many days need to pass between marking entity for deletion and actual removing it." +msgstr "" + msgid "How many replicas each Elasticsearch shard has." msgstr "" @@ -12242,6 +12266,9 @@ msgstr "" msgid "Only Project Members" msgstr "" +msgid "Only active this projects shows up in the search and on the dashboard." +msgstr "" + msgid "Only admins" msgstr "" @@ -13640,6 +13667,9 @@ msgstr "" msgid "Project '%{project_name}' is in the process of being deleted." msgstr "" +msgid "Project '%{project_name}' is restored." +msgstr "" + msgid "Project '%{project_name}' queued for deletion." msgstr "" @@ -13649,6 +13679,9 @@ msgstr "" msgid "Project '%{project_name}' was successfully updated." msgstr "" +msgid "Project '%{project_name}' will be deleted on %{date}" +msgstr "" + msgid "Project Badges" msgstr "" @@ -13670,6 +13703,9 @@ msgstr "" msgid "Project already created" msgstr "" +msgid "Project already deleted" +msgstr "" + msgid "Project and wiki repositories" msgstr "" @@ -14588,6 +14624,9 @@ msgstr "" msgid "Query" msgstr "" +msgid "Query cannot be processed" +msgstr "" + msgid "Query is valid" msgstr "" @@ -14931,6 +14970,12 @@ msgstr "" msgid "Removes time estimate." msgstr "" +msgid "Removing a project places it into a read-only state until %{date}, at which point the project will be permanantly removed. Are you ABSOLUTELY sure?" +msgstr "" + +msgid "Removing a project places it into a read-only state until %{date}, at which point the project will be permanently removed." +msgstr "" + msgid "Removing group will cause all child projects and resources to be removed." msgstr "" @@ -15226,6 +15271,12 @@ msgstr "" msgid "Restart Terminal" msgstr "" +msgid "Restore project" +msgstr "" + +msgid "Restoring the project will prevent the project from being removed on this date and restore people's ability to make changes to it." +msgstr "" + msgid "Restrict access by IP address" msgstr "" @@ -17681,6 +17732,9 @@ msgstr "" msgid "The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project." msgstr "" +msgid "The Prometheus server responded with \"bad request\". Please check your queries are correct and are supported in your Prometheus version. %{documentationLink}" +msgstr "" + msgid "The URL to use for connecting to Elasticsearch. Use a comma-separated list to support clustering (e.g., \"http://localhost:9200, http://localhost:9201\")." msgstr "" @@ -17711,7 +17765,7 @@ msgstr "" msgid "The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository." msgstr "" -msgid "The data source is connected, but there is no data to display." +msgid "The data source is connected, but there is no data to display. %{documentationLink}" msgstr "" msgid "The default CI configuration path for new projects." @@ -17881,6 +17935,9 @@ msgstr "" msgid "The remote repository is being updated..." msgstr "" +msgid "The repository can be commited to, and issues, comments and other entities can be created." +msgstr "" + msgid "The repository for this project does not exist." msgstr "" @@ -18397,6 +18454,9 @@ msgstr "" msgid "This project path either does not exist or is private." msgstr "" +msgid "This project will be removed on %{date}" +msgstr "" + msgid "This repository" msgstr "" @@ -19157,7 +19217,7 @@ msgstr "" msgid "Unarchive project" msgstr "" -msgid "Unarchiving the project will restore people's ability to make changes to it. The repository can be committed to, and issues, comments and other entities can be created. <strong>Once active this project shows up in the search and on the dashboard.</strong>" +msgid "Unarchiving the project will restore people's ability to make changes to it. The repository can be committed to, and issues, comments, and other entities can be created. %{strong_start}Once active, this project shows up in the search and on the dashboard.%{strong_end}" msgstr "" msgid "Unblock" @@ -19268,6 +19328,9 @@ msgstr "" msgid "Until" msgstr "" +msgid "Until that time, the project can be restored." +msgstr "" + msgid "Unverified" msgstr "" @@ -19886,6 +19949,9 @@ msgstr "" msgid "Verify SAML Configuration" msgstr "" +msgid "Verify configuration" +msgstr "" + msgid "Version" msgstr "" @@ -20095,6 +20161,9 @@ msgstr "" msgid "We could not determine the path to remove the issue" msgstr "" +msgid "We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating." +msgstr "" + msgid "We created a short guided tour that will help you learn the basics of GitLab and how it will help you be better at your job. It should only take a couple of minutes. You will be guided by two types of helpers, best recognized by their color." msgstr "" @@ -20949,6 +21018,9 @@ msgstr "" msgid "among other things" msgstr "" +msgid "archived" +msgstr "" + msgid "assign yourself" msgstr "" @@ -21893,6 +21965,9 @@ msgstr "" msgid "pending comment" msgstr "" +msgid "pending removal" +msgstr "" + msgid "pipeline" msgstr "" diff --git a/qa/qa/page/project/settings/advanced.rb b/qa/qa/page/project/settings/advanced.rb index 3a5067a9541..c95c47fa560 100644 --- a/qa/qa/page/project/settings/advanced.rb +++ b/qa/qa/page/project/settings/advanced.rb @@ -12,6 +12,9 @@ module QA element :project_path_field element :change_path_button element :transfer_button + end + + view 'app/views/projects/settings/_archive.html.haml' do element :archive_project_link element :unarchive_project_link end diff --git a/scripts/ee-specific-lines-check b/scripts/ee-specific-lines-check deleted file mode 100755 index 4114575168c..00000000000 --- a/scripts/ee-specific-lines-check +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env ruby - -require_relative 'ee_specific_check/ee_specific_check' - -include EESpecificCheck # rubocop:disable Style/MixinUsage -git_version - -base = find_compare_base - -current_numstat = updated_diff_numstat(base.ce_base, base.ee_base) -updated_numstat = updated_diff_numstat(base.ce_head, base.ee_head) - -offenses = updated_numstat.select do |file, updated_delta| - current_delta = current_numstat[file] - - more_lines = updated_delta > current_delta - - more_lines && - !WHITELIST.any? { |pattern| Dir.glob(pattern, File::FNM_DOTMATCH).include?(file) } -end - -if offenses.empty? - say "🎉 All good, congrats! 🎉" -else - puts - - offenses.each do |(file, delta)| - puts "* 💥 #{file} has #{delta - current_numstat[file]} updated lines that differ between EE and CE! 💥" - end - - say <<~MESSAGE - ℹ️ Make sure all lines in shared files have been updated in your backport merge request and the branch name includes #{minimal_ce_branch_name}. - ℹ️ Consider using an EE module to add the features you want. - ℹ️ See this for detail: https://docs.gitlab.com/ee/development/ee_features.html#ee-features-based-on-ce-features - MESSAGE -end - -remove_remotes - -say "ℹ️ For more information on why, see https://gitlab.com/gitlab-org/gitlab/issues/2952" - -exit(offenses.size) diff --git a/scripts/frontend/check_no_partial_karma_jest.sh b/scripts/frontend/check_no_partial_karma_jest.sh index 0d0c897bb18..c5fffa5900b 100755 --- a/scripts/frontend/check_no_partial_karma_jest.sh +++ b/scripts/frontend/check_no_partial_karma_jest.sh @@ -1,6 +1,12 @@ #!/usr/bin/env bash -karma_files=$(find spec/javascripts ee/spec/javascripts -type f -name '*_spec.js' -not -path '*/helpers/*') +karma_directory=spec/javascripts + +if [ -d ee ]; then + karma_directory="$karma_directory ee/$karma_directory" +fi + +karma_files=$(find $karma_directory -type f -name '*_spec.js' -not -path '*/helpers/*') violations="" for karma_file in $karma_files; do diff --git a/spec/controllers/projects/service_hook_logs_controller_spec.rb b/spec/controllers/projects/service_hook_logs_controller_spec.rb new file mode 100644 index 00000000000..ca57b0579a8 --- /dev/null +++ b/spec/controllers/projects/service_hook_logs_controller_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Projects::ServiceHookLogsController do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + let(:service) { create(:drone_ci_service, project: project) } + let(:log) { create(:web_hook_log, web_hook: service.service_hook) } + let(:log_params) do + { + namespace_id: project.namespace, + project_id: project, + service_id: service.to_param, + id: log.id + } + end + + before do + sign_in(user) + project.add_maintainer(user) + end + + describe 'GET #show' do + subject { get :show, params: log_params } + + it do + expect(response).to be_successful + end + end + + describe 'POST #retry' do + subject { post :retry, params: log_params } + + it 'executes the hook and redirects to the service form' do + expect_any_instance_of(ServiceHook).to receive(:execute) + expect_any_instance_of(described_class).to receive(:set_hook_execution_notice) + expect(subject).to redirect_to(edit_project_service_path(project, service)) + end + end +end diff --git a/spec/factories/services.rb b/spec/factories/services.rb index f9c77dbf87f..b6bb30d1f93 100644 --- a/spec/factories/services.rb +++ b/spec/factories/services.rb @@ -44,6 +44,13 @@ FactoryBot.define do end end + factory :drone_ci_service do + project + active { true } + drone_url { 'https://bamboo.example.com' } + token { 'test' } + end + factory :jira_service do project active { true } diff --git a/spec/frontend/monitoring/__snapshots__/dashboard_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap index 5f24bab600c..5f24bab600c 100644 --- a/spec/frontend/monitoring/__snapshots__/dashboard_state_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap diff --git a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap new file mode 100644 index 00000000000..7f37a83d291 --- /dev/null +++ b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GroupEmptyState Renders an empty state for BAD_QUERY 1`] = ` +<glemptystate-stub + compact="true" + primarybuttonlink="/path/to/settings" + primarybuttontext="Verify configuration" + svgpath="/path/to/empty-group-illustration.svg" + title="Query cannot be processed" +/> +`; + +exports[`GroupEmptyState Renders an empty state for BAD_QUERY 2`] = `"The Prometheus server responded with \\"bad request\\". Please check your queries are correct and are supported in your Prometheus version. <a href=\\"/path/to/docs\\">More information</a>"`; + +exports[`GroupEmptyState Renders an empty state for CONNECTION_FAILED 1`] = ` +<glemptystate-stub + compact="true" + description="We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating." + primarybuttonlink="/path/to/settings" + primarybuttontext="Verify configuration" + svgpath="/path/to/empty-group-illustration.svg" + title="Connection failed" +/> +`; + +exports[`GroupEmptyState Renders an empty state for CONNECTION_FAILED 2`] = `undefined`; + +exports[`GroupEmptyState Renders an empty state for FOO STATE 1`] = ` +<glemptystate-stub + compact="true" + description="An error occurred while loading the data. Please try again." + svgpath="/path/to/empty-group-illustration.svg" + title="An error has occurred" +/> +`; + +exports[`GroupEmptyState Renders an empty state for FOO STATE 2`] = `undefined`; + +exports[`GroupEmptyState Renders an empty state for LOADING 1`] = ` +<glemptystate-stub + compact="true" + description="Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available." + svgpath="/path/to/empty-group-illustration.svg" + title="Waiting for performance data" +/> +`; + +exports[`GroupEmptyState Renders an empty state for LOADING 2`] = `undefined`; + +exports[`GroupEmptyState Renders an empty state for NO_DATA 1`] = ` +<glemptystate-stub + compact="true" + svgpath="/path/to/empty-group-illustration.svg" + title="No data to display" +/> +`; + +exports[`GroupEmptyState Renders an empty state for NO_DATA 2`] = `"The data source is connected, but there is no data to display. <a href=\\"/path/to/docs\\">More information</a>"`; + +exports[`GroupEmptyState Renders an empty state for TIMEOUT 1`] = ` +<glemptystate-stub + compact="true" + svgpath="/path/to/empty-group-illustration.svg" + title="Connection timed out" +/> +`; + +exports[`GroupEmptyState Renders an empty state for TIMEOUT 2`] = `"Charts can't be displayed as the request for data has timed out. <a href=\\"/path/to/docs\\">More information</a>"`; + +exports[`GroupEmptyState Renders an empty state for UNKNOWN_ERROR 1`] = ` +<glemptystate-stub + compact="true" + description="An error occurred while loading the data. Please try again." + svgpath="/path/to/empty-group-illustration.svg" + title="An error has occurred" +/> +`; + +exports[`GroupEmptyState Renders an empty state for UNKNOWN_ERROR 2`] = `undefined`; diff --git a/spec/frontend/monitoring/dashboard_state_spec.js b/spec/frontend/monitoring/components/empty_state_spec.js index e985e5fb443..e985e5fb443 100644 --- a/spec/frontend/monitoring/dashboard_state_spec.js +++ b/spec/frontend/monitoring/components/empty_state_spec.js diff --git a/spec/frontend/monitoring/components/group_empty_state_spec.js b/spec/frontend/monitoring/components/group_empty_state_spec.js new file mode 100644 index 00000000000..e8ef8192067 --- /dev/null +++ b/spec/frontend/monitoring/components/group_empty_state_spec.js @@ -0,0 +1,34 @@ +import { shallowMount } from '@vue/test-utils'; +import GroupEmptyState from '~/monitoring/components/group_empty_state.vue'; +import { metricStates } from '~/monitoring/constants'; + +function createComponent(props) { + return shallowMount(GroupEmptyState, { + propsData: { + ...props, + documentationPath: '/path/to/docs', + settingsPath: '/path/to/settings', + svgPath: '/path/to/empty-group-illustration.svg', + }, + }); +} + +describe('GroupEmptyState', () => { + const supportedStates = [ + metricStates.NO_DATA, + metricStates.TIMEOUT, + metricStates.CONNECTION_FAILED, + metricStates.BAD_QUERY, + metricStates.LOADING, + metricStates.UNKNOWN_ERROR, + 'FOO STATE', // does not fail with unknown states + ]; + + test.each(supportedStates)('Renders an empty state for %s', selectedState => { + const wrapper = createComponent({ selectedState }); + + expect(wrapper.element).toMatchSnapshot(); + // slot is not rendered by the stub, test it separately + expect(wrapper.vm.currentState.slottedDescription).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index 92d469270c9..f38bd4384e2 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -529,7 +529,7 @@ describe('Monitoring store actions', () => { }, }, { - type: types.RECEIVE_METRIC_RESULT_ERROR, + type: types.RECEIVE_METRIC_RESULT_FAILURE, payload: { metricId: metric.metric_id, error, diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js index 3b6f33ed8b1..9e325fe3cf9 100644 --- a/spec/frontend/monitoring/store/getters_spec.js +++ b/spec/frontend/monitoring/store/getters_spec.js @@ -1,7 +1,7 @@ import * as getters from '~/monitoring/stores/getters'; - import mutations from '~/monitoring/stores/mutations'; import * as types from '~/monitoring/stores/mutation_types'; +import { metricStates } from '~/monitoring/constants'; import { metricsGroupsAPIResponse, mockedEmptyResult, @@ -10,6 +10,124 @@ import { } from '../mock_data'; describe('Monitoring store Getters', () => { + describe('getMetricStates', () => { + let setupState; + let state; + let getMetricStates; + + beforeEach(() => { + setupState = (initState = {}) => { + state = initState; + getMetricStates = getters.getMetricStates(state); + }; + }); + + it('has method-style access', () => { + setupState(); + + expect(getMetricStates).toEqual(expect.any(Function)); + }); + + it('when dashboard has no panel groups, returns empty', () => { + setupState({ + dashboard: { + panel_groups: [], + }, + }); + + expect(getMetricStates()).toEqual([]); + }); + + describe('when the dashboard is set', () => { + let groups; + beforeEach(() => { + setupState({ + dashboard: { panel_groups: [] }, + }); + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse); + groups = state.dashboard.panel_groups; + }); + + it('no loaded metric returns empty', () => { + expect(getMetricStates()).toEqual([]); + }); + + it('on an empty metric with no result, returns NO_DATA', () => { + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse); + mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, mockedEmptyResult); + + expect(getMetricStates()).toEqual([metricStates.NO_DATA]); + }); + + it('on a metric with a result, returns OK', () => { + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse); + mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, mockedQueryResultPayload); + + expect(getMetricStates()).toEqual([metricStates.OK]); + }); + + it('on a metric with an error, returns an error', () => { + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse); + mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { + metricId: groups[0].panels[0].metrics[0].metricId, + }); + + expect(getMetricStates()).toEqual([metricStates.UNKNOWN_ERROR]); + }); + + it('on multiple metrics with results, returns OK', () => { + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse); + mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, mockedQueryResultPayload); + mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, mockedQueryResultPayloadCoresTotal); + + expect(getMetricStates()).toEqual([metricStates.OK]); + + // Filtered by groups + expect(getMetricStates(state.dashboard.panel_groups[0].key)).toEqual([]); + expect(getMetricStates(state.dashboard.panel_groups[1].key)).toEqual([metricStates.OK]); + }); + it('on multiple metrics errors', () => { + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse); + + mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { + metricId: groups[0].panels[0].metrics[0].metricId, + }); + mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { + metricId: groups[1].panels[0].metrics[0].metricId, + }); + mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { + metricId: groups[1].panels[1].metrics[0].metricId, + }); + + // Entire dashboard fails + expect(getMetricStates()).toEqual([metricStates.UNKNOWN_ERROR]); + expect(getMetricStates(groups[0].key)).toEqual([metricStates.UNKNOWN_ERROR]); + expect(getMetricStates(groups[1].key)).toEqual([metricStates.UNKNOWN_ERROR]); + }); + + it('on multiple metrics with errors', () => { + mutations[types.RECEIVE_METRICS_DATA_SUCCESS](state, metricsGroupsAPIResponse); + + // An success in 1 group + mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, mockedQueryResultPayload); + // An error in 2 groups + mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { + metricId: groups[0].panels[0].metrics[0].metricId, + }); + mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { + metricId: groups[1].panels[1].metrics[0].metricId, + }); + + expect(getMetricStates()).toEqual([metricStates.OK, metricStates.UNKNOWN_ERROR]); + expect(getMetricStates(groups[0].key)).toEqual([metricStates.UNKNOWN_ERROR]); + expect(getMetricStates(groups[1].key)).toEqual([ + metricStates.OK, + metricStates.UNKNOWN_ERROR, + ]); + }); + }); + }); + describe('metricsWithData', () => { let metricsWithData; let setupState; diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js index 8da172ec634..60107a03674 100644 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -3,7 +3,7 @@ import httpStatusCodes from '~/lib/utils/http_status'; import mutations from '~/monitoring/stores/mutations'; import * as types from '~/monitoring/stores/mutation_types'; import state from '~/monitoring/stores/state'; -import { metricsErrors } from '~/monitoring/constants'; +import { metricStates } from '~/monitoring/constants'; import { metricsGroupsAPIResponse, deploymentData, @@ -120,7 +120,7 @@ describe('Monitoring mutations', () => { expect.objectContaining({ loading: true, result: null, - error: null, + state: metricStates.LOADING, }), ); }); @@ -153,20 +153,20 @@ describe('Monitoring mutations', () => { expect(getMetric()).toEqual( expect.objectContaining({ loading: false, - error: null, + state: metricStates.OK, }), ); }); }); - describe('RECEIVE_METRIC_RESULT_ERROR', () => { + describe('RECEIVE_METRIC_RESULT_FAILURE', () => { beforeEach(() => { mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups); }); it('maintains the loading state when a metric fails', () => { expect(stateCopy.showEmptyState).toBe(true); - mutations[types.RECEIVE_METRIC_RESULT_ERROR](stateCopy, { + mutations[types.RECEIVE_METRIC_RESULT_FAILURE](stateCopy, { metricId, error: 'an error', }); @@ -175,7 +175,7 @@ describe('Monitoring mutations', () => { }); it('stores a timeout error in a metric', () => { - mutations[types.RECEIVE_METRIC_RESULT_ERROR](stateCopy, { + mutations[types.RECEIVE_METRIC_RESULT_FAILURE](stateCopy, { metricId, error: { message: 'BACKOFF_TIMEOUT' }, }); @@ -184,13 +184,13 @@ describe('Monitoring mutations', () => { expect.objectContaining({ loading: false, result: null, - error: metricsErrors.TIMEOUT, + state: metricStates.TIMEOUT, }), ); }); it('stores a connection failed error in a metric', () => { - mutations[types.RECEIVE_METRIC_RESULT_ERROR](stateCopy, { + mutations[types.RECEIVE_METRIC_RESULT_FAILURE](stateCopy, { metricId, error: { response: { @@ -202,13 +202,13 @@ describe('Monitoring mutations', () => { expect.objectContaining({ loading: false, result: null, - error: metricsErrors.CONNECTION_FAILED, + state: metricStates.CONNECTION_FAILED, }), ); }); it('stores a bad data error in a metric', () => { - mutations[types.RECEIVE_METRIC_RESULT_ERROR](stateCopy, { + mutations[types.RECEIVE_METRIC_RESULT_FAILURE](stateCopy, { metricId, error: { response: { @@ -221,13 +221,13 @@ describe('Monitoring mutations', () => { expect.objectContaining({ loading: false, result: null, - error: metricsErrors.BAD_DATA, + state: metricStates.BAD_QUERY, }), ); }); it('stores an unknown error in a metric', () => { - mutations[types.RECEIVE_METRIC_RESULT_ERROR](stateCopy, { + mutations[types.RECEIVE_METRIC_RESULT_FAILURE](stateCopy, { metricId, error: null, // no reason in response }); @@ -236,7 +236,7 @@ describe('Monitoring mutations', () => { expect.objectContaining({ loading: false, result: null, - error: metricsErrors.UNKNOWN_ERROR, + state: metricStates.UNKNOWN_ERROR, }), ); }); diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js index 708c5ea75e0..0ee114cb70d 100644 --- a/spec/javascripts/ide/stores/actions_spec.js +++ b/spec/javascripts/ide/stores/actions_spec.js @@ -92,26 +92,58 @@ describe('Multi-file store actions', () => { .catch(done.fail); }); - it('closes the temp file if it was open', done => { + it('closes the temp file and deletes it if it was open', done => { f.tempFile = true; testAction( discardAllChanges, undefined, store.state, + [{ type: types.REMOVE_ALL_CHANGES_FILES }], [ - { type: types.DISCARD_FILE_CHANGES, payload: 'discardAll' }, - { type: types.REMOVE_ALL_CHANGES_FILES }, + { type: 'closeFile', payload: jasmine.objectContaining({ path: 'discardAll' }) }, + { type: 'deleteEntry', payload: 'discardAll' }, ], + done, + ); + }); + + it('renames the file to its original name and closes it if it was open', done => { + Object.assign(f, { + prevPath: 'parent/path/old_name', + prevName: 'old_name', + prevParentPath: 'parent/path', + }); + + testAction( + discardAllChanges, + undefined, + store.state, + [{ type: types.REMOVE_ALL_CHANGES_FILES }], [ + { type: 'closeFile', payload: jasmine.objectContaining({ path: 'discardAll' }) }, { - type: 'closeFile', - payload: jasmine.objectContaining({ path: 'discardAll' }), + type: 'renameEntry', + payload: { path: 'discardAll', name: 'old_name', parentPath: 'parent/path' }, }, ], done, ); }); + + it('discards file changes on all other files', done => { + testAction( + discardAllChanges, + undefined, + store.state, + [ + { type: types.DISCARD_FILE_CHANGES, payload: 'discardAll' }, + { type: types.REMOVE_ALL_CHANGES_FILES }, + ], + [], + done, + ); + }); }); describe('closeAllFiles', () => { diff --git a/spec/javascripts/monitoring/components/dashboard_spec.js b/spec/javascripts/monitoring/components/dashboard_spec.js index 37a811f153f..b29bac21820 100644 --- a/spec/javascripts/monitoring/components/dashboard_spec.js +++ b/spec/javascripts/monitoring/components/dashboard_spec.js @@ -4,7 +4,8 @@ import { GlToast } from '@gitlab/ui'; import VueDraggable from 'vuedraggable'; import MockAdapter from 'axios-mock-adapter'; import Dashboard from '~/monitoring/components/dashboard.vue'; -import EmptyState from '~/monitoring/components/empty_state.vue'; +import { metricStates } from '~/monitoring/constants'; +import GroupEmptyState from '~/monitoring/components/group_empty_state.vue'; import * as types from '~/monitoring/stores/mutation_types'; import { createStore } from '~/monitoring/stores'; import axios from '~/lib/utils/axios_utils'; @@ -401,7 +402,7 @@ describe('Dashboard', () => { }); beforeEach(done => { - createComponentWrapper({ hasMetrics: true }, { attachToDocument: true }); + createComponentWrapper({ hasMetrics: true }); setupComponentStore(wrapper.vm); wrapper.vm.$nextTick(done); @@ -411,16 +412,16 @@ describe('Dashboard', () => { const emptyGroup = wrapper.findAll({ ref: 'empty-group' }); expect(emptyGroup).toHaveLength(1); - expect(emptyGroup.is(EmptyState)).toBe(true); + expect(emptyGroup.is(GroupEmptyState)).toBe(true); }); - it('group empty area displays a "noDataGroup"', () => { + it('group empty area displays a NO_DATA state', () => { expect( wrapper .findAll({ ref: 'empty-group' }) .at(0) .props('selectedState'), - ).toEqual('noDataGroup'); + ).toEqual(metricStates.NO_DATA); }); }); diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index 9e55fbcce20..2c141cae98d 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -421,4 +421,21 @@ describe Blob do end end end + + describe 'policy' do + let(:project) { build(:project) } + subject { described_class.new(fake_blob(path: 'foo'), project) } + + it 'works with policy' do + expect(Ability.allowed?(project.creator, :read_blob, subject)).to be_truthy + end + + context 'when project is nil' do + subject { described_class.new(fake_blob(path: 'foo')) } + + it 'does not err' do + expect(Ability.allowed?(project.creator, :read_blob, subject)).to be_falsey + end + end + end end diff --git a/spec/models/concerns/safe_url_spec.rb b/spec/models/concerns/safe_url_spec.rb new file mode 100644 index 00000000000..3244410181e --- /dev/null +++ b/spec/models/concerns/safe_url_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe SafeUrl do + describe '#safe_url' do + class TestClass + include SafeUrl + + attr_reader :url + + def initialize(url) + @url = url + end + end + + let(:test_class) { TestClass.new(url) } + let(:url) { 'http://example.com' } + + subject { test_class.safe_url } + + it { is_expected.to eq(url) } + + context 'when URL contains credentials' do + let(:url) { 'http://foo:bar@example.com' } + + it { is_expected.to eq('http://*****:*****@example.com')} + + context 'when username is whitelisted' do + subject { test_class.safe_url(usernames_whitelist: usernames_whitelist) } + + let(:usernames_whitelist) { %w[foo] } + + it 'does expect the whitelisted username not to be masked' do + is_expected.to eq('http://foo:*****@example.com') + end + end + end + + context 'when URL is empty' do + let(:url) { nil } + + it { is_expected.to be_nil } + end + + context 'when URI raises an error' do + let(:url) { 123 } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/hooks/web_hook_log_spec.rb b/spec/models/hooks/web_hook_log_spec.rb index 85934b81086..22aad2fab0a 100644 --- a/spec/models/hooks/web_hook_log_spec.rb +++ b/spec/models/hooks/web_hook_log_spec.rb @@ -29,6 +29,25 @@ describe WebHookLog do end end + describe '#save' do + let(:web_hook_log) { build(:web_hook_log, url: url) } + let(:url) { 'http://example.com' } + + subject { web_hook_log.save! } + + it { is_expected.to eq(true) } + + context 'with basic auth credentials' do + let(:url) { 'http://test:123@example.com'} + + it 'obfuscates the basic auth credentials' do + subject + + expect(web_hook_log.url).to eq('http://*****:*****@example.com') + end + end + end + describe '#success?' do let(:web_hook_log) { build(:web_hook_log, response_status: status) } diff --git a/spec/models/readme_blob_spec.rb b/spec/models/readme_blob_spec.rb new file mode 100644 index 00000000000..f07713bd908 --- /dev/null +++ b/spec/models/readme_blob_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ReadmeBlob do + include FakeBlobHelpers + + describe 'policy' do + let(:project) { build(:project, :repository) } + subject { described_class.new(fake_blob(path: 'README.md'), project.repository) } + + it 'works with policy' do + expect(Ability.allowed?(project.creator, :read_blob, subject)).to be_truthy + end + end +end diff --git a/spec/policies/blob_policy_spec.rb b/spec/policies/blob_policy_spec.rb new file mode 100644 index 00000000000..20c8a55f437 --- /dev/null +++ b/spec/policies/blob_policy_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe BlobPolicy do + include_context 'ProjectPolicyTable context' + include ProjectHelpers + using RSpec::Parameterized::TableSyntax + + let(:project) { create(:project, :repository, project_level) } + let(:user) { create_user_from_membership(project, membership) } + let(:blob) { project.repository.blob_at(SeedRepo::FirstCommit::ID, 'README.md') } + + subject(:policy) { described_class.new(user, blob) } + + where(:project_level, :feature_access_level, :membership, :expected_count) do + permission_table_for_guest_feature_access_and_non_private_project_only + end + + with_them do + it "grants permission" do + update_feature_access_level(project, feature_access_level) + + if expected_count == 1 + expect(policy).to be_allowed(:read_blob) + else + expect(policy).to be_disallowed(:read_blob) + end + end + end +end diff --git a/spec/policies/wiki_page_policy_spec.rb b/spec/policies/wiki_page_policy_spec.rb new file mode 100644 index 00000000000..e550ccf6d65 --- /dev/null +++ b/spec/policies/wiki_page_policy_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe WikiPagePolicy do + include_context 'ProjectPolicyTable context' + include ProjectHelpers + using RSpec::Parameterized::TableSyntax + + let(:project) { create(:project, :wiki_repo, project_level) } + let(:user) { create_user_from_membership(project, membership) } + let(:wiki_page) { create(:wiki_page, wiki: project.wiki) } + + subject(:policy) { described_class.new(user, wiki_page) } + + where(:project_level, :feature_access_level, :membership, :expected_count) do + permission_table_for_guest_feature_access + end + + with_them do + it "grants permission" do + update_feature_access_level(project, feature_access_level) + + if expected_count == 1 + expect(policy).to be_allowed(:read_wiki_page) + else + expect(policy).to be_disallowed(:read_wiki_page) + end + end + end +end diff --git a/spec/presenters/hooks/project_hook_presenter_spec.rb b/spec/presenters/hooks/project_hook_presenter_spec.rb new file mode 100644 index 00000000000..773e8ccf51e --- /dev/null +++ b/spec/presenters/hooks/project_hook_presenter_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ProjectHookPresenter do + let(:web_hook_log) { create(:web_hook_log) } + let(:project) { web_hook_log.web_hook.project } + let(:web_hook) { web_hook_log.web_hook } + + describe '#logs_details_path' do + subject { web_hook.present.logs_details_path(web_hook_log) } + + let(:expected_path) do + "/#{project.namespace.path}/#{project.name}/hooks/#{web_hook.id}/hook_logs/#{web_hook_log.id}" + end + + it { is_expected.to eq(expected_path) } + end + + describe '#logs_retry_path' do + subject { web_hook.present.logs_details_path(web_hook_log) } + + let(:expected_path) do + "/#{project.namespace.path}/#{project.name}/hooks/#{web_hook.id}/hook_logs/#{web_hook_log.id}" + end + + it { is_expected.to eq(expected_path) } + end +end diff --git a/spec/presenters/hooks/service_hook_presenter_spec.rb b/spec/presenters/hooks/service_hook_presenter_spec.rb new file mode 100644 index 00000000000..bea57768e3e --- /dev/null +++ b/spec/presenters/hooks/service_hook_presenter_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe ServiceHookPresenter do + let(:web_hook_log) { create(:web_hook_log, web_hook: service_hook) } + let(:service_hook) { create(:service_hook, service: service) } + let(:service) { create(:drone_ci_service, project: project) } + let(:project) { create(:project) } + + describe '#logs_details_path' do + subject { service_hook.present.logs_details_path(web_hook_log) } + + let(:expected_path) do + "/#{project.namespace.path}/#{project.name}/-/services/#{service.to_param}/hook_logs/#{web_hook_log.id}" + end + + it { is_expected.to eq(expected_path) } + end + + describe '#logs_retry_path' do + subject { service_hook.present.logs_retry_path(web_hook_log) } + + let(:expected_path) do + "/#{project.namespace.path}/#{project.name}/-/services/#{service.to_param}/hook_logs/#{web_hook_log.id}/retry" + end + + it { is_expected.to eq(expected_path) } + end +end diff --git a/spec/presenters/web_hook_log_presenter_spec.rb b/spec/presenters/web_hook_log_presenter_spec.rb new file mode 100644 index 00000000000..8812a0ba594 --- /dev/null +++ b/spec/presenters/web_hook_log_presenter_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe WebHookLogPresenter do + include Gitlab::Routing.url_helpers + + describe '#details_path' do + let(:web_hook_log) { create(:web_hook_log, web_hook: web_hook) } + let(:project) { create(:project) } + + subject { web_hook_log.present.details_path } + + context 'project hook' do + let(:web_hook) { create(:project_hook, project: project) } + + it { is_expected.to eq(project_hook_hook_log_path(project, web_hook, web_hook_log)) } + end + + context 'service hook' do + let(:web_hook) { create(:service_hook, service: service) } + let(:service) { create(:drone_ci_service, project: project) } + + it { is_expected.to eq(project_service_hook_log_path(project, service, web_hook_log)) } + end + end + + describe '#retry_path' do + let(:web_hook_log) { create(:web_hook_log, web_hook: web_hook) } + let(:project) { create(:project) } + + subject { web_hook_log.present.retry_path } + + context 'project hook' do + let(:web_hook) { create(:project_hook, project: project) } + + it { is_expected.to eq(retry_project_hook_hook_log_path(project, web_hook, web_hook_log)) } + end + + context 'service hook' do + let(:web_hook) { create(:service_hook, service: service) } + let(:service) { create(:drone_ci_service, project: project) } + + it { is_expected.to eq(retry_project_service_hook_log_path(project, service, web_hook_log)) } + end + end +end diff --git a/spec/requests/api/graphql/mutations/snippets/mark_as_spam_spec.rb b/spec/requests/api/graphql/mutations/snippets/mark_as_spam_spec.rb new file mode 100644 index 00000000000..0e8fe4987b9 --- /dev/null +++ b/spec/requests/api/graphql/mutations/snippets/mark_as_spam_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Mark snippet as spam' do + include GraphqlHelpers + + let_it_be(:admin) { create(:admin) } + let_it_be(:other_user) { create(:user) } + let_it_be(:snippet) { create(:personal_snippet) } + let_it_be(:user_agent_detail) { create(:user_agent_detail, subject: snippet) } + let(:current_user) { snippet.author } + let(:mutation) do + variables = { + id: snippet.to_global_id.to_s + } + + graphql_mutation(:mark_as_spam_snippet, variables) + end + + def mutation_response + graphql_mutation_response(:mark_as_spam_snippet) + end + + shared_examples 'does not mark the snippet as spam' do + it do + expect do + post_graphql_mutation(mutation, current_user: current_user) + end.not_to change { snippet.reload.user_agent_detail.submitted } + end + end + + context 'when the user does not have permission' do + let(:current_user) { other_user } + + it_behaves_like 'a mutation that returns top-level errors', + errors: [Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR] + + it_behaves_like 'does not mark the snippet as spam' + end + + context 'when the user has permission' do + context 'when user can not mark snippet as spam' do + it_behaves_like 'does not mark the snippet as spam' + end + + context 'when user can mark snippet as spam' do + let(:current_user) { admin } + + before do + stub_application_setting(akismet_enabled: true) + end + + it 'marks snippet as spam' do + expect_next_instance_of(SpamService) do |instance| + expect(instance).to receive(:mark_as_spam!) + end + + post_graphql_mutation(mutation, current_user: current_user) + end + end + end +end diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb index 2a4368868d5..d8f13bc2e61 100644 --- a/spec/services/web_hook_service_spec.rb +++ b/spec/services/web_hook_service_spec.rb @@ -203,17 +203,6 @@ describe WebHookService do expect(hook_log.internal_error_message).to be_nil end end - - context 'should not log ServiceHooks' do - let(:service_hook) { create(:service_hook) } - let(:service_instance) { described_class.new(service_hook, data, 'service_hook') } - - before do - stub_full_request(service_hook.url, method: :post).to_return(status: 200, body: 'Success') - end - - it { expect { service_instance.execute }.not_to change(WebHookLog, :count) } - end end end diff --git a/spec/views/projects/services/edit.html.haml_spec.rb b/spec/views/projects/services/edit.html.haml_spec.rb new file mode 100644 index 00000000000..12e1cda2c00 --- /dev/null +++ b/spec/views/projects/services/edit.html.haml_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'projects/services/edit' do + let(:service) { create(:drone_ci_service, project: project) } + let(:project) { create(:project) } + + before do + assign :project, project + assign :service, service + end + + it do + render + + expect(rendered).not_to have_text('Recent Deliveries') + end + + context 'service using WebHooks' do + before do + assign(:web_hook_logs, []) + end + + it do + render + + expect(rendered).to have_text('Recent Deliveries') + end + end +end |