diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-20 12:08:30 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-02-20 12:08:30 +0300 |
commit | bd28d0fa02dc73794e013159512900f8d10fa10b (patch) | |
tree | 57ca25e2ecb1f6d379f9738ccc140fa1c624e6c3 | |
parent | 24e54a8f10e88aafa48c8a7dc548576939e6612b (diff) |
Add latest changes from gitlab-org/gitlab@master
59 files changed, 881 insertions, 132 deletions
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue new file mode 100644 index 00000000000..a25b3ca09fd --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue @@ -0,0 +1,89 @@ +<script> +import { + GlAccordionItem, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlTokenSelector, + GlFormCombobox, +} from '@gitlab/ui'; +import { mapState } from 'vuex'; +import { i18n } from '../constants'; + +export default { + i18n, + components: { + GlAccordionItem, + GlFormGroup, + GlFormInput, + GlFormTextarea, + GlFormCombobox, + GlTokenSelector, + }, + props: { + tagOptions: { + type: Array, + required: true, + }, + job: { + type: Object, + required: true, + }, + isNameValid: { + type: Boolean, + required: true, + }, + isScriptValid: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapState(['availableStages']), + }, +}; +</script> +<template> + <gl-accordion-item :title="$options.i18n.JOB_SETUP" visible> + <gl-form-group + :invalid-feedback="$options.i18n.THIS_FIELD_IS_REQUIRED" + :state="isNameValid" + :label="$options.i18n.JOB_NAME" + > + <gl-form-input + :value="job.name" + :state="isNameValid" + data-testid="job-name-input" + @input="$emit('update-job', 'name', $event)" + /> + </gl-form-group> + <gl-form-combobox + :value="job.stage" + :token-list="availableStages" + :label-text="$options.i18n.STAGE" + data-testid="job-stage-input" + @input="$emit('update-job', 'stage', $event)" + /> + <gl-form-group + :invalid-feedback="$options.i18n.THIS_FIELD_IS_REQUIRED" + :state="isScriptValid" + :label="$options.i18n.SCRIPT" + > + <gl-form-textarea + :value="job.script" + :state="isScriptValid" + :no-resize="false" + data-testid="job-script-input" + @input="$emit('update-job', 'script', $event)" + /> + </gl-form-group> + <gl-form-group :label="$options.i18n.TAGS"> + <gl-token-selector + :dropdown-items="tagOptions" + :selected-tokens="job.tags" + data-testid="job-tags-input" + @input="$emit('update-job', 'tags', $event)" + /> + </gl-form-group> + </gl-accordion-item> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js index 1c122fd5e38..05a616596e2 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js @@ -1,7 +1,38 @@ -import { s__ } from '~/locale'; +import { __, s__ } from '~/locale'; export const DRAWER_CONTAINER_CLASS = '.content-wrapper'; +export const JOB_TEMPLATE = { + name: '', + stage: '', + script: '', + tags: [], + image: { + name: '', + entrypoint: '', + }, + services: [ + { + name: '', + entrypoint: '', + }, + ], + artifacts: { + paths: [''], + exclude: [''], + }, + cache: { + paths: [''], + key: '', + }, +}; + export const i18n = { ADD_JOB: s__('JobAssistant|Add job'), + SCRIPT: s__('JobAssistant|Script'), + JOB_NAME: s__('JobAssistant|Job name'), + JOB_SETUP: s__('JobAssistant|Job Setup'), + STAGE: s__('JobAssistant|Stage (optional)'), + TAGS: s__('JobAssistant|Tags (optional)'), + THIS_FIELD_IS_REQUIRED: __('This field is required'), }; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue index 65c87df21cb..d9df32ad84e 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue @@ -1,13 +1,23 @@ <script> -import { GlDrawer, GlButton } from '@gitlab/ui'; +import { GlDrawer, GlAccordion, GlButton } from '@gitlab/ui'; +import { stringify } from 'yaml'; +import { mapMutations, mapState } from 'vuex'; +import { set, omit, trim } from 'lodash'; import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; -import { DRAWER_CONTAINER_CLASS, i18n } from './constants'; +import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub'; +import { UPDATE_CI_CONFIG } from '~/ci/pipeline_editor/store/mutation_types'; +import getAllRunners from '~/ci/runner/graphql/list/all_runners.query.graphql'; +import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, i18n } from './constants'; +import { removeEmptyObj, trimFields } from './utils'; +import JobSetupItem from './accordion_items/job_setup_item.vue'; export default { i18n, components: { GlDrawer, + GlAccordion, GlButton, + JobSetupItem, }, props: { isVisible: { @@ -21,15 +31,84 @@ export default { default: 200, }, }, + data() { + return { + isNameValid: true, + isScriptValid: true, + job: JSON.parse(JSON.stringify(JOB_TEMPLATE)), + }; + }, + apollo: { + runners: { + query: getAllRunners, + update(data) { + return data?.runners?.nodes || []; + }, + }, + }, computed: { + ...mapState(['currentCiFileContent']), + tagOptions() { + const options = []; + this.runners?.forEach((runner) => options.push(...runner.tagList)); + return [...new Set(options)].map((tag) => { + return { + id: tag, + name: tag, + }; + }); + }, drawerHeightOffset() { return getContentWrapperHeight(DRAWER_CONTAINER_CLASS); }, }, methods: { + ...mapMutations({ + updateCiConfig: UPDATE_CI_CONFIG, + }), closeDrawer() { + this.clearJob(); this.$emit('close-job-assistant-drawer'); }, + addCiConfig() { + this.isNameValid = this.validate(this.job.name); + this.isScriptValid = this.validate(this.job.script); + + if (!this.isNameValid || !this.isScriptValid) { + return; + } + + const newJobString = this.generateYmlString(); + this.updateCiConfig(`${this.currentCiFileContent}\n${newJobString}`); + eventHub.$emit(SCROLL_EDITOR_TO_BOTTOM); + + this.closeDrawer(); + }, + generateYmlString() { + let job = JSON.parse(JSON.stringify(this.job)); + const jobName = job.name; + job = omit(job, ['name']); + job.tags = job.tags.map((tag) => tag.name); // Tag item is originally an option object, we need a string here to match `.gitlab-ci.yml` rules + const cleanedJob = trimFields(removeEmptyObj(job)); + return stringify({ [jobName]: cleanedJob }); + }, + clearJob() { + this.job = JSON.parse(JSON.stringify(JOB_TEMPLATE)); + this.isNameValid = true; + this.isScriptValid = true; + }, + updateJob(key, value) { + set(this.job, key, value); + if (key === 'name') { + this.isNameValid = this.validate(this.job.name); + } + if (key === 'script') { + this.isScriptValid = this.validate(this.job.script); + } + }, + validate(value) { + return trim(value) !== ''; + }, }, }; </script> @@ -44,6 +123,15 @@ export default { <template #title> <h2 class="gl-m-0 gl-font-lg">{{ $options.i18n.ADD_JOB }}</h2> </template> + <gl-accordion :header-level="3"> + <job-setup-item + :tag-options="tagOptions" + :job="job" + :is-name-valid="isNameValid" + :is-script-valid="isScriptValid" + @update-job="updateJob" + /> + </gl-accordion> <template #footer> <div class="gl-display-flex gl-justify-content-end"> <gl-button @@ -51,11 +139,15 @@ export default { class="gl-mr-3" data-testid="cancel-button" @click="closeDrawer" - >{{ __('Cancel') }}</gl-button - > - <gl-button category="primary" variant="confirm" data-testid="confirm-button">{{ - __('Add') - }}</gl-button> + >{{ __('Cancel') }} + </gl-button> + <gl-button + category="primary" + variant="confirm" + data-testid="confirm-button" + @click="addCiConfig" + >{{ __('Add') }} + </gl-button> </div> </template> </gl-drawer> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js new file mode 100644 index 00000000000..83e7574c4de --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js @@ -0,0 +1,22 @@ +import { isEmpty, isObject, isArray, isString, reject, omitBy, mapValues, map, trim } from 'lodash'; + +const isEmptyValue = (val) => (isObject(val) || isString(val)) && isEmpty(val); +const trimText = (val) => (isString(val) ? trim(val) : val); + +export const removeEmptyObj = (obj) => { + if (isArray(obj)) { + return reject(map(obj, removeEmptyObj), isEmptyValue); + } else if (isObject(obj)) { + return omitBy(mapValues(obj, removeEmptyObj), isEmptyValue); + } + return obj; +}; + +export const trimFields = (data) => { + if (isArray(data)) { + return data.map(trimFields); + } else if (isObject(data)) { + return mapValues(data, trimFields); + } + return trimText(data); +}; diff --git a/app/assets/javascripts/ci/pipeline_editor/event_hub.js b/app/assets/javascripts/ci/pipeline_editor/event_hub.js new file mode 100644 index 00000000000..c64eaf5ef5c --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/event_hub.js @@ -0,0 +1,5 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); + +export const SCROLL_EDITOR_TO_BOTTOM = Symbol('scrollEditorToBottom'); diff --git a/app/assets/javascripts/ci/pipeline_editor/index.js b/app/assets/javascripts/ci/pipeline_editor/index.js index 6d91c339833..50d1cb42f5c 100644 --- a/app/assets/javascripts/ci/pipeline_editor/index.js +++ b/app/assets/javascripts/ci/pipeline_editor/index.js @@ -12,6 +12,7 @@ import getPipelineEtag from './graphql/queries/client/pipeline_etag.query.graphq import { resolvers } from './graphql/resolvers'; import typeDefs from './graphql/typedefs.graphql'; import PipelineEditorApp from './pipeline_editor_app.vue'; +import createStore from './store'; export const initPipelineEditor = (selector = '#js-pipeline-editor') => { const el = document.querySelector(selector); @@ -111,8 +112,11 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { }, }); + const store = createStore(); + return new Vue({ el, + store, apolloProvider, provide: { ciConfigPath, diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue index ff848a973e3..7b3c4d6f74f 100644 --- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue @@ -1,10 +1,12 @@ <script> import { GlLoadingIcon, GlModal } from '@gitlab/ui'; +import { mapState, mapMutations } from 'vuex'; +import { parse } from 'yaml'; import { fetchPolicies } from '~/lib/graphql'; import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; - import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; +import { UPDATE_CI_CONFIG, UPDATE_AVAILABLE_STAGES } from './store/mutation_types'; import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue'; import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue'; @@ -44,7 +46,6 @@ export default { data() { return { ciConfigData: {}, - currentCiFileContent: '', failureType: null, failureReasons: [], hasBranchLoaded: false, @@ -94,7 +95,7 @@ export default { const fileContent = rawBlob ?? ''; this.lastCommittedContent = fileContent; - this.currentCiFileContent = fileContent; + this.updateCiConfig(fileContent); // If rawBlob is defined and returns a string, it means that there is // a CI config file with empty content. If `rawBlob` is not defined @@ -155,6 +156,10 @@ export default { this.isLintUnavailable = false; } } + + if (data?.ciConfig?.mergedYaml) { + this.updateAvailableStages(parse(data.ciConfig.mergedYaml).stages); + } }, error() { // We are not using `reportFailure` here because we don't @@ -231,6 +236,7 @@ export default { }, }, computed: { + ...mapState(['currentCiFileContent']), hasUnsavedChanges() { return this.lastCommittedContent !== this.currentCiFileContent; }, @@ -294,6 +300,10 @@ export default { this.checkShouldSkipStartScreen(); }, methods: { + ...mapMutations({ + updateCiConfig: UPDATE_CI_CONFIG, + updateAvailableStages: UPDATE_AVAILABLE_STAGES, + }), checkShouldSkipStartScreen() { const params = queryToObject(window.location.search); this.shouldSkipStartScreen = Boolean(params?.add_new_config_file); @@ -344,7 +354,7 @@ export default { }, resetContent() { this.showResetConfirmationModal = false; - this.currentCiFileContent = this.lastCommittedContent; + this.updateCiConfig(this.lastCommittedContent); }, setAppStatus(appStatus) { if (EDITOR_APP_VALID_STATUSES.includes(appStatus)) { @@ -361,9 +371,6 @@ export default { showErrorAlert({ type, reasons = [] }) { this.reportFailure(type, reasons); }, - updateCiConfig(ciFileContent) { - this.currentCiFileContent = ciFileContent; - }, updateCommitSha() { this.isFetchingCommitSha = true; this.$apollo.queries.commitSha.refetch(); diff --git a/app/assets/javascripts/ci/pipeline_editor/store/index.js b/app/assets/javascripts/ci/pipeline_editor/store/index.js new file mode 100644 index 00000000000..d7d5aed79e2 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/store/index.js @@ -0,0 +1,12 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + mutations, + state: state(), + }); diff --git a/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js b/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js new file mode 100644 index 00000000000..035d3c90c14 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/store/mutation_types.js @@ -0,0 +1,2 @@ +export const UPDATE_CI_CONFIG = 'UPDATE_CI_CONFIG'; +export const UPDATE_AVAILABLE_STAGES = 'UPDATE_AVAILABLE_STAGES'; diff --git a/app/assets/javascripts/ci/pipeline_editor/store/mutations.js b/app/assets/javascripts/ci/pipeline_editor/store/mutations.js new file mode 100644 index 00000000000..552c1df9a2c --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/store/mutations.js @@ -0,0 +1,10 @@ +import * as types from './mutation_types'; + +export default { + [types.UPDATE_CI_CONFIG](state, content) { + state.currentCiFileContent = content; + }, + [types.UPDATE_AVAILABLE_STAGES](state, stages) { + state.availableStages = stages || []; + }, +}; diff --git a/app/assets/javascripts/ci/pipeline_editor/store/state.js b/app/assets/javascripts/ci/pipeline_editor/store/state.js new file mode 100644 index 00000000000..34146cd54c4 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/store/state.js @@ -0,0 +1,4 @@ +export default () => ({ + currentCiFileContent: '', + availableStages: [], +}); diff --git a/app/finders/abuse_reports_finder.rb b/app/finders/abuse_reports_finder.rb index 04043f36426..90d09a2d6ed 100644 --- a/app/finders/abuse_reports_finder.rb +++ b/app/finders/abuse_reports_finder.rb @@ -1,18 +1,50 @@ # frozen_string_literal: true class AbuseReportsFinder - attr_reader :params + attr_reader :params, :reports def initialize(params = {}) @params = params + @reports = AbuseReport.all end def execute - reports = AbuseReport.all - reports = reports.by_user(params[:user_id]) if params[:user_id].present? + filter_reports reports.with_order_id_desc .with_users .page(params[:page]) end + + private + + def filter_reports + filter_by_user_id + + filter_by_status + filter_by_category + end + + def filter_by_status + return unless params[:status].present? + + case params[:status] + when 'open' + @reports = @reports.open + when 'closed' + @reports = @reports.closed + end + end + + def filter_by_category + return unless params[:category].present? + + @reports = @reports.by_category(params[:category]) + end + + def filter_by_user_id + return unless params[:user_id].present? + + @reports = @reports.by_user_id(params[:user_id]) + end end diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index dbcdfa5e946..1d2d077e0ff 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -42,7 +42,8 @@ class AbuseReport < ApplicationRecord before_validation :filter_empty_strings_from_links_to_spam validate :links_to_spam_contains_valid_urls - scope :by_user, ->(user) { where(user_id: user) } + scope :by_user_id, ->(id) { where(user_id: id) } + scope :by_category, ->(category) { where(category: category) } scope :with_users, -> { includes(:reporter, :user) } enum category: { @@ -56,6 +57,11 @@ class AbuseReport < ApplicationRecord other: 8 } + enum status: { + open: 1, + closed: 2 + } + # For CacheMarkdownField alias_method :author, :reporter diff --git a/app/serializers/admin/abuse_report_entity.rb b/app/serializers/admin/abuse_report_entity.rb new file mode 100644 index 00000000000..a550763f0ff --- /dev/null +++ b/app/serializers/admin/abuse_report_entity.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Admin + class AbuseReportEntity < Grape::Entity + expose :category + expose :updated_at + + expose :reported_user do |report| + UserEntity.represent(report.user, only: [:name]) + end + + expose :reporter do |report| + UserEntity.represent(report.reporter, only: [:name]) + end + end +end diff --git a/app/serializers/admin/abuse_report_serializer.rb b/app/serializers/admin/abuse_report_serializer.rb new file mode 100644 index 00000000000..af43e459482 --- /dev/null +++ b/app/serializers/admin/abuse_report_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Admin + class AbuseReportSerializer < BaseSerializer + entity Admin::AbuseReportEntity + end +end diff --git a/app/services/issues/after_create_service.rb b/app/services/issues/after_create_service.rb index 011a78029c8..5d10eca2979 100644 --- a/app/services/issues/after_create_service.rb +++ b/app/services/issues/after_create_service.rb @@ -2,11 +2,6 @@ module Issues class AfterCreateService < Issues::BaseService - # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container - def initialize(container:, current_user: nil, params: {}) - super(project: container, current_user: current_user, params: params) - end - def execute(issue) todo_service.new_issue(issue, current_user) delete_milestone_total_issue_counter_cache(issue.milestone) diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 553fb6e2ac9..fa46ba748e8 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -6,6 +6,10 @@ module Issues include IncidentManagement::UsageData include IssueTypeHelpers + def initialize(container:, current_user: nil, params: {}) + super(project: container, current_user: current_user, params: params) + end + def hook_data(issue, action, old_associations: {}) hook_data = issue.to_hook_data(current_user, old_associations: old_associations) hook_data[:object_attributes][:action] = action @@ -33,6 +37,14 @@ module Issues private + # overriding this because IssuableBaseService#constructor_container_arg returns { project: value } + # Issues::ReopenService constructor signature is different now, it takes container instead of project also + # IssuableBaseService#change_state dynamically picks one of the `Issues::ReopenService`, `Epics::ReopenService` or + # MergeRequests::ReopenService, so we need this method to return { }container: value } for Issues::ReopenService + def self.constructor_container_arg(value) + { container: value } + end + def find_work_item_type_id(issue_type) work_item_type = WorkItems::Type.default_by_type(issue_type) work_item_type ||= WorkItems::Type.default_issue_type diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 877ce09e065..75bd2b88e86 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -4,11 +4,6 @@ module Issues class BuildService < Issues::BaseService include ResolveDiscussions - # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container - def initialize(container:, current_user: nil, params: {}) - super(project: container, current_user: current_user, params: params) - end - def execute filter_resolve_discussion_params diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 9fde1cc2ac2..4f6a859e20e 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -2,11 +2,6 @@ module Issues class CloseService < Issues::BaseService - # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container - def initialize(container:, current_user: nil, params: {}) - super(project: container, current_user: current_user, params: params) - end - # Closes the supplied issue if the current user is able to do so. def execute(issue, commit: nil, notifications: true, system_note: true, skip_authorization: false) return issue unless can_close?(issue, skip_authorization: skip_authorization) @@ -56,11 +51,6 @@ module Issues private - # TODO: remove once MergeRequests::CloseService or IssuableBaseService method is changed. - def self.constructor_container_arg(value) - { container: value } - end - def can_close?(issue, skip_authorization: false) skip_authorization || can?(current_user, :update_issue, issue) || issue.is_a?(ExternalIssue) end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index fa5233da489..f03599242c1 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -15,9 +15,10 @@ module Issues # SpamParams constructor are not otherwise available, spam_params: must be explicitly passed as nil. def initialize(container:, spam_params:, current_user: nil, params: {}, build_service: nil) @extra_params = params.delete(:extra_params) || {} - super(project: container, current_user: current_user, params: params) + super(container: container, current_user: current_user, params: params) @spam_params = spam_params - @build_service = build_service || BuildService.new(container: project, current_user: current_user, params: params) + @build_service = build_service || + BuildService.new(container: container, current_user: current_user, params: params) end def execute(skip_system_notes: false) @@ -100,10 +101,6 @@ module Issues private - def self.constructor_container_arg(value) - { container: value } - end - def handle_quick_actions(issue) # Do not handle quick actions unless the work item is the default Issue. # The available quick actions for a work item depend on its type and widgets. diff --git a/app/services/issues/duplicate_service.rb b/app/services/issues/duplicate_service.rb index a3213c50f86..1fff9a4a684 100644 --- a/app/services/issues/duplicate_service.rb +++ b/app/services/issues/duplicate_service.rb @@ -2,11 +2,6 @@ module Issues class DuplicateService < Issues::BaseService - # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container - def initialize(container:, current_user: nil, params: {}) - super(project: container, current_user: current_user, params: params) - end - def execute(duplicate_issue, canonical_issue) return if canonical_issue == duplicate_issue return unless can?(current_user, :update_issue, duplicate_issue) diff --git a/app/services/issues/referenced_merge_requests_service.rb b/app/services/issues/referenced_merge_requests_service.rb index ba03927136a..a69cd324b1e 100644 --- a/app/services/issues/referenced_merge_requests_service.rb +++ b/app/services/issues/referenced_merge_requests_service.rb @@ -2,11 +2,6 @@ module Issues class ReferencedMergeRequestsService < Issues::BaseService - # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container - def initialize(container:, current_user: nil, params: {}) - super(project: container, current_user: current_user, params: params) - end - # rubocop: disable CodeReuse/ActiveRecord def execute(issue) referenced = referenced_merge_requests(issue) diff --git a/app/services/issues/related_branches_service.rb b/app/services/issues/related_branches_service.rb index 3f4413fdfd7..ef6de83fcf4 100644 --- a/app/services/issues/related_branches_service.rb +++ b/app/services/issues/related_branches_service.rb @@ -4,11 +4,6 @@ # those with a merge request open referencing the current issue. module Issues class RelatedBranchesService < Issues::BaseService - # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container - def initialize(container:, current_user: nil, params: {}) - super(project: container, current_user: current_user, params: params) - end - def execute(issue) branch_names_with_mrs = branches_with_merge_request_for(issue) branches = branches_with_iid_of(issue).reject { |b| branch_names_with_mrs.include?(b[:name]) } diff --git a/app/services/issues/reopen_service.rb b/app/services/issues/reopen_service.rb index ebcf2fb5c83..f4f81e9455a 100644 --- a/app/services/issues/reopen_service.rb +++ b/app/services/issues/reopen_service.rb @@ -2,11 +2,6 @@ module Issues class ReopenService < Issues::BaseService - # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container - def initialize(container:, current_user: nil, params: {}) - super(project: container, current_user: current_user, params: params) - end - def execute(issue, skip_authorization: false) return issue unless can_reopen?(issue, skip_authorization: skip_authorization) @@ -27,14 +22,6 @@ module Issues private - # overriding this because IssuableBaseService#constructor_container_arg returns { project: value } - # Issues::ReopenService constructor signature is different now, it takes container instead of project also - # IssuableBaseService#change_state dynamically picks one of the `Issues::ReopenService`, `Epics::ReopenService` or - # MergeRequests::ReopenService, so we need this method to return { }container: value } for Issues::ReopenService - def self.constructor_container_arg(value) - { container: value } - end - def can_reopen?(issue, skip_authorization: false) skip_authorization || can?(current_user, :reopen_issue, issue) end diff --git a/app/services/issues/reorder_service.rb b/app/services/issues/reorder_service.rb index 059b4196b23..1afec4c94f4 100644 --- a/app/services/issues/reorder_service.rb +++ b/app/services/issues/reorder_service.rb @@ -4,11 +4,6 @@ module Issues class ReorderService < Issues::BaseService include Gitlab::Utils::StrongMemoize - # TODO: this is to be removed once we get to rename the IssuableBaseService project param to container - def initialize(container:, current_user: nil, params: {}) - super(project: container, current_user: current_user, params: params) - end - def execute(issue) return false unless can?(current_user, :update_issue, issue) return false unless move_between_ids diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 71324b3f044..322065c5b7c 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -6,7 +6,7 @@ module Issues # necessary in many cases, and we don't want to require every caller to explicitly pass it as nil # to disable spam checking. def initialize(container:, current_user: nil, params: {}, spam_params: nil) - super(project: container, current_user: current_user, params: params) + super(container: container, current_user: current_user, params: params) @spam_params = spam_params end @@ -116,15 +116,6 @@ module Issues attr_reader :spam_params - # TODO: remove this once MergeRequests::UpdateService#initialize is changed to take container as named argument. - # - # Issues::UpdateService is used together with MergeRequests::UpdateService in Mutations::Assignable#assign! method - # however MergeRequests::UpdateService#initialize still takes `project` as param and Issues::UpdateService is being - # changed to take `container` as param. So we are adding this workaround in the meantime. - def self.constructor_container_arg(value) - { container: value } - end - def handle_quick_actions(issue) # Do not handle quick actions unless the work item is the default Issue. # The available quick actions for a work item depend on its type and widgets. diff --git a/app/services/issues/zoom_link_service.rb b/app/services/issues/zoom_link_service.rb index 4144c293990..bfd3e6a945f 100644 --- a/app/services/issues/zoom_link_service.rb +++ b/app/services/issues/zoom_link_service.rb @@ -3,7 +3,7 @@ module Issues class ZoomLinkService < Issues::BaseService def initialize(container:, current_user:, params:) - super(project: container, current_user: current_user, params: params) + super @issue = params.fetch(:issue) @added_meeting = ZoomMeeting.canonical_meeting(@issue) diff --git a/app/workers/issues/placement_worker.rb b/app/workers/issues/placement_worker.rb index ec29a754128..0a4f2612912 100644 --- a/app/workers/issues/placement_worker.rb +++ b/app/workers/issues/placement_worker.rb @@ -40,7 +40,7 @@ module Issues leftover = to_place.pop if to_place.count > QUERY_LIMIT Issue.move_nulls_to_end(to_place) - Issues::BaseService.new(project: nil).rebalance_if_needed(to_place.max_by(&:relative_position)) + Issues::BaseService.new(container: nil).rebalance_if_needed(to_place.max_by(&:relative_position)) Issues::PlacementWorker.perform_async(nil, leftover.project_id) if leftover.present? rescue RelativePositioning::NoSpaceLeft => e Gitlab::ErrorTracking.log_exception(e, issue_id: issue_id, project_id: project_id) diff --git a/db/migrate/20230216040505_add_status_and_resolved_at_to_abuse_reports.rb b/db/migrate/20230216040505_add_status_and_resolved_at_to_abuse_reports.rb new file mode 100644 index 00000000000..3cfd082b465 --- /dev/null +++ b/db/migrate/20230216040505_add_status_and_resolved_at_to_abuse_reports.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddStatusAndResolvedAtToAbuseReports < Gitlab::Database::Migration[2.1] + def change + add_column :abuse_reports, :status, :integer, limit: 2, default: 1, null: false + add_timestamps_with_timezone(:abuse_reports, columns: [:resolved_at], null: true) + end +end diff --git a/db/migrate/20230216071312_add_status_category_and_id_index_to_abuse_reports.rb b/db/migrate/20230216071312_add_status_category_and_id_index_to_abuse_reports.rb new file mode 100644 index 00000000000..0c529f15b1b --- /dev/null +++ b/db/migrate/20230216071312_add_status_category_and_id_index_to_abuse_reports.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddStatusCategoryAndIdIndexToAbuseReports < Gitlab::Database::Migration[2.1] + INDEX_NAME = 'index_abuse_reports_on_status_category_and_id' + + disable_ddl_transaction! + + def up + add_concurrent_index :abuse_reports, [:status, :category, :id], name: INDEX_NAME + end + + def down + remove_concurrent_index_by_name :abuse_reports, INDEX_NAME + end +end diff --git a/db/migrate/20230220035034_add_status_and_id_index_to_abuse_reports.rb b/db/migrate/20230220035034_add_status_and_id_index_to_abuse_reports.rb new file mode 100644 index 00000000000..cea01572e37 --- /dev/null +++ b/db/migrate/20230220035034_add_status_and_id_index_to_abuse_reports.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddStatusAndIdIndexToAbuseReports < Gitlab::Database::Migration[2.1] + INDEX_NAME = 'index_abuse_reports_on_status_and_id' + + disable_ddl_transaction! + + def up + add_concurrent_index :abuse_reports, [:status, :id], name: INDEX_NAME + end + + def down + remove_concurrent_index_by_name :abuse_reports, INDEX_NAME + end +end diff --git a/db/post_migrate/20230213103019_add_index_for_next_over_limit_check_at.rb b/db/post_migrate/20230213103019_add_index_for_next_over_limit_check_at.rb new file mode 100644 index 00000000000..29c59cea3ff --- /dev/null +++ b/db/post_migrate/20230213103019_add_index_for_next_over_limit_check_at.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexForNextOverLimitCheckAt < Gitlab::Database::Migration[2.1] + TABLE_NAME = 'namespace_details' + INDEX_NAME = 'index_next_over_limit_check_at_asc_order' + + def up + prepare_async_index TABLE_NAME, + :next_over_limit_check_at, + order: { next_over_limit_check_at: 'ASC NULLS FIRST' }, + name: INDEX_NAME + end + + def down + unprepare_async_index TABLE_NAME, INDEX_NAME + end +end diff --git a/db/schema_migrations/20230213103019 b/db/schema_migrations/20230213103019 new file mode 100644 index 00000000000..e28052b7f58 --- /dev/null +++ b/db/schema_migrations/20230213103019 @@ -0,0 +1 @@ +23979065610c4f361a639cdcf81e7ce491d111ed3752bd11081f9645b31e21f6
\ No newline at end of file diff --git a/db/schema_migrations/20230216040505 b/db/schema_migrations/20230216040505 new file mode 100644 index 00000000000..d3cc858827f --- /dev/null +++ b/db/schema_migrations/20230216040505 @@ -0,0 +1 @@ +c6a905e29792b88f87810d267a4472886e0a1a22fe9531e3d7998abbd1035552
\ No newline at end of file diff --git a/db/schema_migrations/20230216071312 b/db/schema_migrations/20230216071312 new file mode 100644 index 00000000000..2e92ecc19e6 --- /dev/null +++ b/db/schema_migrations/20230216071312 @@ -0,0 +1 @@ +204503fcf9e5da7255677a9a82f11e860410048efc1ed75cc7ba97b3cdd273c3
\ No newline at end of file diff --git a/db/schema_migrations/20230220035034 b/db/schema_migrations/20230220035034 new file mode 100644 index 00000000000..4cb8be66d8f --- /dev/null +++ b/db/schema_migrations/20230220035034 @@ -0,0 +1 @@ +f5636e464b16bfc201a3f3a21269c6d8686d2bc829aa80491bea120fd10e138a
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 7e394f6bb7e..61b0e28c8ad 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -10733,6 +10733,8 @@ CREATE TABLE abuse_reports ( category smallint DEFAULT 1 NOT NULL, reported_from_url text DEFAULT ''::text NOT NULL, links_to_spam text[] DEFAULT '{}'::text[] NOT NULL, + status smallint DEFAULT 1 NOT NULL, + resolved_at timestamp with time zone, CONSTRAINT abuse_reports_links_to_spam_length_check CHECK ((cardinality(links_to_spam) <= 20)), CONSTRAINT check_ab1260fa6c CHECK ((char_length(reported_from_url) <= 512)) ); @@ -28991,6 +28993,10 @@ CREATE UNIQUE INDEX idx_vulnerability_issue_links_on_vulnerability_id_and_link_t CREATE UNIQUE INDEX idx_work_item_types_on_namespace_id_and_name_null_namespace ON work_item_types USING btree (btrim(lower(name)), ((namespace_id IS NULL))) WHERE (namespace_id IS NULL); +CREATE INDEX index_abuse_reports_on_status_and_id ON abuse_reports USING btree (status, id); + +CREATE INDEX index_abuse_reports_on_status_category_and_id ON abuse_reports USING btree (status, category, id); + CREATE INDEX index_abuse_reports_on_user_id ON abuse_reports USING btree (user_id); CREATE UNIQUE INDEX "index_achievements_on_namespace_id_LOWER_name" ON achievements USING btree (namespace_id, lower(name)); diff --git a/doc/ci/yaml/includes.md b/doc/ci/yaml/includes.md index bf0b7444e78..79f0e870740 100644 --- a/doc/ci/yaml/includes.md +++ b/doc/ci/yaml/includes.md @@ -159,12 +159,18 @@ do not change. This method is called *merging*. ### Merge method for `include` -For a file containing `include` directives, the included files are read in order (possibly -recursively), and the configuration in these files is likewise merged in order. If the parameters overlap, the last included file takes precedence. Finally, the directives in the -file itself are merged with the configuration from the included files. +The `include` configuration merges with the main configuration file with this process: + +- Included files are read in the order defined in the configuration file, and + the included configuration is merged together in the same order. +- If an included file also uses `include`, that nested `include` configuration is merged first (recursively). +- If parameters overlap, the last included file takes precedence when merging the configuration + from the included files. +- After all configuration added with `include` is merged together, the main configuration + is merged with the included configuration. This merge method is a _deep merge_, where hash maps are merged at any depth in the -configuration. To merge hash map A (containing the configuration merged so far) and B (the next piece +configuration. To merge hash map "A" (that contains the configuration merged so far) and "B" (the next piece of configuration), the keys and values are processed as follows: - When the key only exists in A, use the key and value from A. @@ -172,9 +178,7 @@ of configuration), the keys and values are processed as follows: - When the key exists in both A and B, and one of the values is not a hash map, use the value from B. - Otherwise, use the key and value from B. -For example: - -We have a configuration consisting of two files. +For example, with a configuration that consists of two files: - The `.gitlab-ci.yml` file: @@ -211,7 +215,7 @@ We have a configuration consisting of two files. dotenv: deploy.env ``` -The merged result: +The merged result is: ```yaml variables: diff --git a/doc/development/documentation/styleguide/word_list.md b/doc/development/documentation/styleguide/word_list.md index e64fd4df7ff..d13f255d2c6 100644 --- a/doc/development/documentation/styleguide/word_list.md +++ b/doc/development/documentation/styleguide/word_list.md @@ -1314,7 +1314,10 @@ See also [downgrade](#downgrade) and [roll back](#roll-back). ## upper left, upper right -Use **upper left** and **upper right** instead of **top left** and **top right**. Hyphenate as adjectives (for example, **upper-left corner**). +Use **upper-left corner** and **upper-right corner** to provide direction in the UI. +If the UI element is not in a corner, use **upper left** and **upper right**. + +Do not use **top left** and **top right**. For details, see the [Microsoft style guide](https://learn.microsoft.com/en-us/style-guide/a-z-word-list-term-collections/u/upper-left-upper-right). diff --git a/doc/development/documentation/topic_types/task.md b/doc/development/documentation/topic_types/task.md index 8d23a5f322e..f596ffc9b8b 100644 --- a/doc/development/documentation/topic_types/task.md +++ b/doc/development/documentation/topic_types/task.md @@ -60,6 +60,22 @@ For example, `Create an issue`. If several tasks on a page share prerequisites, you can create a separate topic with the title `Prerequisites`. +## When a task has only one step + +If you need to write a task that has only one step, make that step an unordered list item. +This format helps the step stand out, while keeping it consistent with the rules +for lists. + +For example: + +```markdown +# Create a merge request + +To create a merge request: + +- In the upper-right corner, select **New merge request**. +``` + ### When more than one way exists to perform a task If more than one way exists to perform a task in the UI, you should diff --git a/doc/user/application_security/policies/index.md b/doc/user/application_security/policies/index.md index a214d0d2cec..f1aafc3d38f 100644 --- a/doc/user/application_security/policies/index.md +++ b/doc/user/application_security/policies/index.md @@ -129,19 +129,6 @@ time that the first policy merge request is created. You can use the [Vulnerability-Check Migration](https://gitlab.com/gitlab-org/gitlab/-/snippets/2328089) script to bulk create policies or associate security policy projects with development projects. For instructions and a demonstration of how to use the Vulnerability-Check Migration script, see [this video](https://youtu.be/biU1N26DfBc). -## Scan execution policies - -See [Scan execution policies](scan-execution-policies.md). - -## Scan result policy editor - -See [Scan result policies](scan-result-policies.md). - -## Roadmap - -See the [Category Direction page](https://about.gitlab.com/direction/govern/security_policies/security_policy_management/) -for more information on the product direction of security policies within GitLab. - ## Troubleshooting ### `Branch name 'update-policy-<timestamp>' does not follow the pattern '<branch_name_regex>'` diff --git a/lib/api/entities/application_with_secret.rb b/lib/api/entities/application_with_secret.rb index 1d0acee8624..5679ab4253d 100644 --- a/lib/api/entities/application_with_secret.rb +++ b/lib/api/entities/application_with_secret.rb @@ -4,8 +4,12 @@ module API module Entities # Use with care, this exposes the secret class ApplicationWithSecret < Entities::Application - expose :secret, documentation: { type: 'string', - example: 'ee1dd64b6adc89cf7e2c23099301ccc2c61b441064e9324d963c46902a85ec34' } + expose :secret, documentation: { + type: 'string', + example: 'ee1dd64b6adc89cf7e2c23099301ccc2c61b441064e9324d963c46902a85ec34' + } do |application, _options| + application.plaintext_secret + end end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 725c587b198..200a8079a7b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -24326,9 +24326,24 @@ msgstr "" msgid "JobAssistant|Add job" msgstr "" +msgid "JobAssistant|Job Setup" +msgstr "" + msgid "JobAssistant|Job assistant" msgstr "" +msgid "JobAssistant|Job name" +msgstr "" + +msgid "JobAssistant|Script" +msgstr "" + +msgid "JobAssistant|Stage (optional)" +msgstr "" + +msgid "JobAssistant|Tags (optional)" +msgstr "" + msgid "Jobs" msgstr "" diff --git a/qa/Gemfile b/qa/Gemfile index a55fb975a04..d5d9b77b82a 100644 --- a/qa/Gemfile +++ b/qa/Gemfile @@ -2,7 +2,7 @@ source 'https://rubygems.org' -gem 'gitlab-qa', '~> 9', require: 'gitlab/qa' +gem 'gitlab-qa', '~> 9', '>= 9.1.0', require: 'gitlab/qa' gem 'activesupport', '~> 6.1.7.2' # This should stay in sync with the root's Gemfile gem 'allure-rspec', '~> 2.20.0' gem 'capybara', '~> 3.38.0' diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock index 96c5bb5f595..10fff3de272 100644 --- a/qa/Gemfile.lock +++ b/qa/Gemfile.lock @@ -102,7 +102,7 @@ GEM gitlab (4.18.0) httparty (~> 0.18) terminal-table (>= 1.5.1) - gitlab-qa (9.0.0) + gitlab-qa (9.1.0) activesupport (~> 6.1) gitlab (~> 4.18.0) http (~> 5.0) @@ -317,7 +317,7 @@ DEPENDENCIES faraday-retry (~> 2.0) fog-core (= 2.1.0) fog-google (~> 1.19) - gitlab-qa (~> 9) + gitlab-qa (~> 9, >= 9.1.0) influxdb-client (~> 2.9) knapsack (~> 4.0) nokogiri (~> 1.14, >= 1.14.2) diff --git a/qa/qa/resource/members.rb b/qa/qa/resource/members.rb index 7f31808d2ff..adb5acc77f1 100644 --- a/qa/qa/resource/members.rb +++ b/qa/qa/resource/members.rb @@ -31,10 +31,14 @@ module QA parse_body(api_get_from("#{api_members_path}/all")) end - def find_member(username) + def find_direct_member(username) list_members.find { |member| member[:username] == username } end + def find_direct_or_inherited_member(username) + list_all_members.find { |member| member[:username] == username } + end + def invite_group(group, access_level = AccessLevel::GUEST) Support::Retrier.retry_until do QA::Runtime::Logger.info(%(Sharing #{self.class.name} with #{group.name})) diff --git a/qa/qa/specs/features/api/1_manage/user_inherited_access_spec.rb b/qa/qa/specs/features/api/1_manage/user_inherited_access_spec.rb index 124b6c9cd44..af78b112be4 100644 --- a/qa/qa/specs/features/api/1_manage/user_inherited_access_spec.rb +++ b/qa/qa/specs/features/api/1_manage/user_inherited_access_spec.rb @@ -39,6 +39,11 @@ module QA before do parent_group.add_member(parent_group_user) + + # Due to the async nature of project authorization refreshes, + # we wait to confirm the user has been added as a member and + # their access level has been updated before proceeding with the test + wait_for_membership_update(parent_group_user, sub_group_project, Resource::Members::AccessLevel::DEVELOPER) end it( @@ -177,6 +182,16 @@ module QA sub_group_user.remove_via_api! end end + + private + + def wait_for_membership_update(user, project, access_level) + Support::Retrier.retry_until(sleep_interval: 1, message: 'Waiting for user membership to be updated') do + found_member = project.reload!.find_direct_or_inherited_member(user.username) + + found_member && found_member.fetch(:access_level) == access_level + end + end end end end diff --git a/qa/qa/specs/features/browser_ui/1_manage/group/group_member_access_request_spec.rb b/qa/qa/specs/features/browser_ui/1_manage/group/group_member_access_request_spec.rb index 3a84646977f..62f596c8915 100644 --- a/qa/qa/specs/features/browser_ui/1_manage/group/group_member_access_request_spec.rb +++ b/qa/qa/specs/features/browser_ui/1_manage/group/group_member_access_request_spec.rb @@ -65,7 +65,7 @@ module QA it 'adds user to the group', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/386792' do - found_member = group.reload!.find_member(user.username) + found_member = group.reload!.find_direct_member(user.username) expect(found_member).not_to be_nil expect(found_member.fetch(:access_level)) @@ -82,7 +82,7 @@ module QA it 'does not add user to the group', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/386793' do - found_member = group.reload!.find_member(user.username) + found_member = group.reload!.find_direct_member(user.username) expect(found_member).to be_nil end diff --git a/spec/factories/abuse_reports.rb b/spec/factories/abuse_reports.rb index 355fb142994..9f05d183ba4 100644 --- a/spec/factories/abuse_reports.rb +++ b/spec/factories/abuse_reports.rb @@ -7,5 +7,9 @@ FactoryBot.define do message { 'User sends spam' } reported_from_url { 'http://gitlab.com' } links_to_spam { ['https://gitlab.com/issue1', 'https://gitlab.com/issue2'] } + + trait :closed do + status { 'closed' } + end end end diff --git a/spec/finders/abuse_reports_finder_spec.rb b/spec/finders/abuse_reports_finder_spec.rb index 52620b3e421..297f3487227 100644 --- a/spec/finders/abuse_reports_finder_spec.rb +++ b/spec/finders/abuse_reports_finder_spec.rb @@ -6,22 +6,48 @@ RSpec.describe AbuseReportsFinder, '#execute' do let(:params) { {} } let!(:user1) { create(:user) } let!(:user2) { create(:user) } - let!(:abuse_report_1) { create(:abuse_report, user: user1) } - let!(:abuse_report_2) { create(:abuse_report, user: user2) } + let!(:abuse_report_1) { create(:abuse_report, category: 'spam', user: user1, reporter: user2) } + let!(:abuse_report_2) { create(:abuse_report, :closed, category: 'phishing', user: user2) } subject { described_class.new(params).execute } - context 'empty params' do + context 'when params is empty' do it 'returns all abuse reports' do expect(subject).to match_array([abuse_report_1, abuse_report_2]) end end - context 'params[:user_id] is present' do + context 'when params[:user_id] is present' do let(:params) { { user_id: user2 } } it 'returns abuse reports for the specified user' do expect(subject).to match_array([abuse_report_2]) end end + + context 'when params[:status] is present' do + context 'when value is "open"' do + let(:params) { { status: 'open' } } + + it 'returns only open abuse reports' do + expect(subject).to match_array([abuse_report_1]) + end + end + + context 'when value is "closed"' do + let(:params) { { status: 'closed' } } + + it 'returns only closed abuse reports' do + expect(subject).to match_array([abuse_report_2]) + end + end + end + + context 'when params[:category] is present' do + let(:params) { { category: 'phishing' } } + + it 'returns abuse reports with the specified category' do + expect(subject).to match_array([abuse_report_2]) + end + end end diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item_spec.js new file mode 100644 index 00000000000..eaad0dae90d --- /dev/null +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item_spec.js @@ -0,0 +1,61 @@ +import createStore from '~/ci/pipeline_editor/store'; +import JobSetupItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants'; + +describe('Job setup item', () => { + let wrapper; + + const findJobNameInput = () => wrapper.findByTestId('job-name-input'); + const findJobScriptInput = () => wrapper.findByTestId('job-script-input'); + const findJobTagsInput = () => wrapper.findByTestId('job-tags-input'); + const findJobStageInput = () => wrapper.findByTestId('job-stage-input'); + + const dummyJobName = 'dummyJobName'; + const dummyJobScript = 'dummyJobScript'; + const dummyJobStage = 'dummyJobStage'; + const dummyJobTags = ['tag1']; + + const createComponent = () => { + wrapper = shallowMountExtended(JobSetupItem, { + store: createStore(), + propsData: { + tagOptions: [ + { id: 'tag1', name: 'tag1' }, + { id: 'tag2', name: 'tag2' }, + ], + isNameValid: true, + isScriptValid: true, + job: JSON.parse(JSON.stringify(JOB_TEMPLATE)), + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('should emit update job event when filling inputs', () => { + expect(wrapper.emitted('update-job')).toBeUndefined(); + + findJobNameInput().vm.$emit('input', dummyJobName); + + expect(wrapper.emitted('update-job')).toHaveLength(1); + expect(wrapper.emitted('update-job')[0]).toEqual(['name', dummyJobName]); + + findJobScriptInput().vm.$emit('input', dummyJobScript); + + expect(wrapper.emitted('update-job')).toHaveLength(2); + expect(wrapper.emitted('update-job')[1]).toEqual(['script', dummyJobScript]); + + findJobStageInput().vm.$emit('input', dummyJobStage); + + expect(wrapper.emitted('update-job')).toHaveLength(3); + expect(wrapper.emitted('update-job')[2]).toEqual(['stage', dummyJobStage]); + + findJobTagsInput().vm.$emit('input', dummyJobTags); + + expect(wrapper.emitted('update-job')).toHaveLength(4); + expect(wrapper.emitted('update-job')[3]).toEqual(['tags', dummyJobTags]); + }); +}); diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js index 79200d92598..f29b6b00e0b 100644 --- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js @@ -1,24 +1,43 @@ import { GlDrawer } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; +import { stringify } from 'yaml'; import JobAssistantDrawer from '~/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue'; +import JobSetupItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue'; +import getAllRunners from '~/ci/runner/graphql/list/all_runners.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import createStore from '~/ci/pipeline_editor/store'; +import { mockAllRunnersQueryResponse } from 'jest/ci/pipeline_editor/mock_data'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub'; Vue.use(VueApollo); describe('Job assistant drawer', () => { let wrapper; + let mockApollo; + + const dummyJobName = 'a'; + const dummyJobScript = 'b'; const findDrawer = () => wrapper.findComponent(GlDrawer); + const findJobSetupItem = () => wrapper.findComponent(JobSetupItem); + const findConfirmButton = () => wrapper.findByTestId('confirm-button'); const findCancelButton = () => wrapper.findByTestId('cancel-button'); const createComponent = () => { + mockApollo = createMockApollo([ + [getAllRunners, jest.fn().mockResolvedValue(mockAllRunnersQueryResponse)], + ]); + wrapper = mountExtended(JobAssistantDrawer, { + store: createStore(), propsData: { isVisible: true, }, + apolloProvider: mockApollo, }); }; @@ -42,4 +61,69 @@ describe('Job assistant drawer', () => { expect(wrapper.emitted('close-job-assistant-drawer')).toHaveLength(1); }); + + it('trigger validate if job name is empty', async () => { + const updateCiConfigSpy = jest.spyOn(wrapper.vm, 'updateCiConfig'); + findJobSetupItem().vm.$emit('update-job', 'script', 'b'); + findConfirmButton().trigger('click'); + + await nextTick(); + + expect(findJobSetupItem().props('isNameValid')).toBe(false); + expect(findJobSetupItem().props('isScriptValid')).toBe(true); + expect(updateCiConfigSpy).toHaveBeenCalledTimes(0); + }); + + describe('when enter valid input', () => { + beforeEach(() => { + findJobSetupItem().vm.$emit('update-job', 'name', dummyJobName); + findJobSetupItem().vm.$emit('update-job', 'script', dummyJobScript); + }); + + it('job name and script have correct value', () => { + expect(findJobSetupItem().props('job')).toMatchObject({ + name: dummyJobName, + script: dummyJobScript, + }); + }); + + it('job name and script state should be valid', () => { + expect(findJobSetupItem().props('isNameValid')).toBe(true); + expect(findJobSetupItem().props('isScriptValid')).toBe(true); + }); + + it('should clear job data when click confirm button', async () => { + findConfirmButton().trigger('click'); + + await nextTick(); + + expect(findJobSetupItem().props('job')).toMatchObject({ name: '', script: '' }); + }); + + it('should clear job data when click cancel button', async () => { + findCancelButton().trigger('click'); + + await nextTick(); + + expect(findJobSetupItem().props('job')).toMatchObject({ name: '', script: '' }); + }); + + it('should update correct ci content when click add button', () => { + const updateCiConfigSpy = jest.spyOn(wrapper.vm, 'updateCiConfig'); + + findConfirmButton().trigger('click'); + + expect(updateCiConfigSpy).toHaveBeenCalledWith( + `\n${stringify({ [dummyJobName]: { script: dummyJobScript } })}`, + ); + }); + + it('should emit scroll editor to button event when click add button', () => { + const eventHubSpy = jest.spyOn(eventHub, '$emit'); + + findConfirmButton().trigger('click'); + + expect(eventHubSpy).toHaveBeenCalledWith(SCROLL_EDITOR_TO_BOTTOM); + }); + }); }); diff --git a/spec/frontend/ci/pipeline_editor/mock_data.js b/spec/frontend/ci/pipeline_editor/mock_data.js index 541123d7efc..93208090d70 100644 --- a/spec/frontend/ci/pipeline_editor/mock_data.js +++ b/spec/frontend/ci/pipeline_editor/mock_data.js @@ -583,6 +583,91 @@ export const mockCommitCreateResponse = { }, }; +export const mockAllRunnersQueryResponse = { + data: { + runners: { + nodes: [ + { + id: 'gid://gitlab/Ci::Runner/1', + description: 'test', + runnerType: 'PROJECT_TYPE', + shortSha: 'DdTYMQGS', + version: '15.6.1', + ipAddress: '127.0.0.1', + active: true, + locked: true, + jobCount: 0, + jobExecutionStatus: 'IDLE', + tagList: ['tag1', 'tag2', 'tag3'], + createdAt: '2022-11-29T09:37:43Z', + contactedAt: null, + status: 'NEVER_CONTACTED', + userPermissions: { + updateRunner: true, + deleteRunner: true, + __typename: 'RunnerPermissions', + }, + groups: null, + ownerProject: { + id: 'gid://gitlab/Project/1', + name: '123', + nameWithNamespace: 'Administrator / 123', + webUrl: 'http://127.0.0.1:3000/root/test', + __typename: 'Project', + }, + __typename: 'CiRunner', + upgradeStatus: 'NOT_AVAILABLE', + adminUrl: 'http://127.0.0.1:3000/admin/runners/1', + editAdminUrl: 'http://127.0.0.1:3000/admin/runners/1/edit', + }, + { + id: 'gid://gitlab/Ci::Runner/2', + description: 'test', + runnerType: 'PROJECT_TYPE', + shortSha: 'DdTYMQGA', + version: '15.6.1', + ipAddress: '127.0.0.1', + active: true, + locked: true, + jobCount: 0, + jobExecutionStatus: 'IDLE', + tagList: ['tag3', 'tag4'], + createdAt: '2022-11-29T09:37:43Z', + contactedAt: null, + status: 'NEVER_CONTACTED', + userPermissions: { + updateRunner: true, + deleteRunner: true, + __typename: 'RunnerPermissions', + }, + groups: null, + ownerProject: { + id: 'gid://gitlab/Project/1', + name: '123', + nameWithNamespace: 'Administrator / 123', + webUrl: 'http://127.0.0.1:3000/root/test', + __typename: 'Project', + }, + __typename: 'CiRunner', + upgradeStatus: 'NOT_AVAILABLE', + adminUrl: 'http://127.0.0.1:3000/admin/runners/2', + editAdminUrl: 'http://127.0.0.1:3000/admin/runners/2/edit', + }, + ], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: + 'eyJjcmVhdGVkX2F0IjoiMjAyMi0xMS0yOSAwOTozNzo0My40OTEwNTEwMDAgKzAwMDAiLCJpZCI6IjIifQ', + endCursor: + 'eyJjcmVhdGVkX2F0IjoiMjAyMi0xMS0yOSAwOTozNzo0My40OTEwNTEwMDAgKzAwMDAiLCJpZCI6IjIifQ', + __typename: 'PageInfo', + }, + __typename: 'CiRunnerConnection', + }, + }, +}; + export const mockCommitCreateResponseNewEtag = { data: { commitCreate: { diff --git a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js index a103acb33bc..5855b805fc9 100644 --- a/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js @@ -8,6 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status'; import { objectToQuery, redirectTo } from '~/lib/utils/url_utility'; import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; +import createStore from '~/ci/pipeline_editor/store'; import PipelineEditorTabs from '~/ci/pipeline_editor/components/pipeline_editor_tabs.vue'; import PipelineEditorEmptyState from '~/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; import PipelineEditorMessages from '~/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue'; @@ -80,7 +81,9 @@ describe('Pipeline editor app component', () => { provide = {}, stubs = {}, } = {}) => { + const store = createStore(); wrapper = shallowMount(PipelineEditorApp, { + store, provide: { ...defaultProvide, ...provide }, stubs, mocks: { @@ -256,6 +259,10 @@ describe('Pipeline editor app component', () => { .mockImplementation(jest.fn()); }); + it('available stages is updated', () => { + expect(wrapper.vm.$store.state.availableStages).toStrictEqual(['test', 'build']); + }); + it('shows pipeline editor home component', () => { expect(findEditorHome().exists()).toBe(true); }); diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb index 7995cc36383..87a0a2a492c 100644 --- a/spec/models/abuse_report_spec.rb +++ b/spec/models/abuse_report_spec.rb @@ -70,6 +70,34 @@ RSpec.describe AbuseReport, feature_category: :insider_threat do } end + describe 'scopes' do + let!(:reporter) { create(:user, username: 'reporter') } + let!(:report1) { create(:abuse_report) } + let!(:report2) { create(:abuse_report, :closed, reporter: reporter, category: 'phishing') } + + describe '.open' do + subject(:results) { described_class.open } + + it 'returns reports without resolved_at value' do + expect(subject).to match_array([report, report1]) + end + end + + describe '.closed' do + subject(:results) { described_class.closed } + + it 'returns reports with resolved_at value' do + expect(subject).to match_array([report2]) + end + end + + describe '.by_category' do + it 'returns abuse reports with the specified category' do + expect(described_class.by_category('phishing')).to match_array([report2]) + end + end + end + describe 'before_validation' do context 'when links to spam contains empty strings' do let(:report) { create(:abuse_report, links_to_spam: ['', 'https://gitlab.com']) } diff --git a/spec/requests/api/applications_spec.rb b/spec/requests/api/applications_spec.rb index b81cdcfea8e..0f7df6661a9 100644 --- a/spec/requests/api/applications_spec.rb +++ b/spec/requests/api/applications_spec.rb @@ -22,7 +22,7 @@ RSpec.describe API::Applications, :api, feature_category: :authentication_and_au expect(json_response).to be_a Hash expect(json_response['application_id']).to eq application.uid - expect(json_response['secret']).to eq application.secret + expect(application.secret_matches?(json_response['secret'])).to eq(true) expect(json_response['callback_url']).to eq application.redirect_uri expect(json_response['confidential']).to eq application.confidential expect(application.scopes.to_s).to eq('api') diff --git a/spec/serializers/admin/abuse_report_entity_spec.rb b/spec/serializers/admin/abuse_report_entity_spec.rb new file mode 100644 index 00000000000..7d18310977c --- /dev/null +++ b/spec/serializers/admin/abuse_report_entity_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Admin::AbuseReportEntity, feature_category: :insider_threat do + let_it_be(:abuse_report) { build_stubbed(:abuse_report) } + + let(:entity) do + described_class.new(abuse_report) + end + + describe '#as_json' do + subject(:entity_hash) { entity.as_json } + + it 'exposes correct attributes' do + expect(entity_hash.keys).to include( + :category, + :updated_at, + :reported_user, + :reporter + ) + end + + it 'correctly exposes `reported user`' do + expect(entity_hash[:reported_user].keys).to match_array([:name]) + end + + it 'correctly exposes `reporter`' do + expect(entity_hash[:reporter].keys).to match_array([:name]) + end + end +end diff --git a/spec/serializers/admin/abuse_report_serializer_spec.rb b/spec/serializers/admin/abuse_report_serializer_spec.rb new file mode 100644 index 00000000000..5b9c229584b --- /dev/null +++ b/spec/serializers/admin/abuse_report_serializer_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Admin::AbuseReportSerializer, feature_category: :insider_threat do + let(:resource) { build(:abuse_report) } + + subject { described_class.new.represent(resource) } + + describe '#represent' do + it 'serializes an abuse report' do + expect(subject[:id]).to eq resource.id + end + + context 'when multiple objects are being serialized' do + let(:resource) { build_list(:abuse_report, 2) } + + it 'serializers the array of abuse reports' do + expect(subject).not_to be_empty + end + end + end +end diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb index 1ac71b966bc..2c8de5ec570 100644 --- a/spec/services/issues/resolve_discussions_spec.rb +++ b/spec/services/issues/resolve_discussions_spec.rb @@ -11,7 +11,7 @@ RSpec.describe Issues::ResolveDiscussions do DummyService.class_eval do include ::Issues::ResolveDiscussions - def initialize(project:, current_user: nil, params: {}) + def initialize(container:, current_user: nil, params: {}) super filter_resolve_discussion_params end @@ -26,7 +26,7 @@ RSpec.describe Issues::ResolveDiscussions do let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: "fix") } describe "#merge_request_for_resolving_discussion" do - let(:service) { DummyService.new(project: project, current_user: user, params: { merge_request_to_resolve_discussions_of: merge_request.iid }) } + let(:service) { DummyService.new(container: project, current_user: user, params: { merge_request_to_resolve_discussions_of: merge_request.iid }) } it "finds the merge request" do expect(service.merge_request_to_resolve_discussions_of).to eq(merge_request) @@ -45,7 +45,7 @@ RSpec.describe Issues::ResolveDiscussions do describe "#discussions_to_resolve" do it "contains a single discussion when matching merge request and discussion are passed" do service = DummyService.new( - project: project, + container: project, current_user: user, params: { discussion_to_resolve: discussion.id, @@ -65,7 +65,7 @@ RSpec.describe Issues::ResolveDiscussions do project: merge_request.target_project, line_number: 15)]) service = DummyService.new( - project: project, + container: project, current_user: user, params: { merge_request_to_resolve_discussions_of: merge_request.iid } ) @@ -83,7 +83,7 @@ RSpec.describe Issues::ResolveDiscussions do line_number: 15 )]) service = DummyService.new( - project: project, + container: project, current_user: user, params: { merge_request_to_resolve_discussions_of: merge_request.iid } ) @@ -96,7 +96,7 @@ RSpec.describe Issues::ResolveDiscussions do it "is empty when a discussion and another merge request are passed" do service = DummyService.new( - project: project, + container: project, current_user: user, params: { discussion_to_resolve: discussion.id, |