diff options
105 files changed, 3903 insertions, 305 deletions
@@ -149,7 +149,7 @@ gem 'wikicloth', '0.8.1' gem 'asciidoctor', '~> 2.0.10' gem 'asciidoctor-include-ext', '~> 0.3.1', require: false gem 'asciidoctor-plantuml', '0.0.10' -gem 'rouge', '~> 3.18.0' +gem 'rouge', '~> 3.19.0' gem 'truncato', '~> 0.7.11' gem 'bootstrap_form', '~> 4.2.0' gem 'nokogiri', '~> 1.10.9' diff --git a/Gemfile.lock b/Gemfile.lock index f3912d5a16e..01431cb3cc0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -894,7 +894,7 @@ GEM retriable (3.1.2) rinku (2.0.0) rotp (2.1.2) - rouge (3.18.0) + rouge (3.19.0) rqrcode (0.7.0) chunky_png rqrcode-rails3 (0.1.7) @@ -1355,7 +1355,7 @@ DEPENDENCIES request_store (~> 1.5) responders (~> 3.0) retriable (~> 3.1.2) - rouge (~> 3.18.0) + rouge (~> 3.19.0) rqrcode-rails3 (~> 0.1.7) rspec-parameterized rspec-rails (~> 4.0.0) diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue index cca56bbe763..435b59f91ef 100644 --- a/app/assets/javascripts/alert_management/components/alert_details.vue +++ b/app/assets/javascripts/alert_management/components/alert_details.vue @@ -2,18 +2,21 @@ import * as Sentry from '@sentry/browser'; import { GlAlert, + GlIcon, GlLoadingIcon, GlNewDropdown, GlNewDropdownItem, + GlSprintf, GlTabs, GlTab, GlButton, } from '@gitlab/ui'; -import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { s__ } from '~/locale'; import query from '../graphql/queries/details.query.graphql'; import { fetchPolicies } from '~/lib/graphql'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { ALERTS_SEVERITY_LABELS } from '../constants'; export default { statuses: { @@ -27,16 +30,21 @@ export default { ), fullAlertDetailsTitle: s__('AlertManagement|Full alert details'), overviewTitle: s__('AlertManagement|Overview'), + reportedAt: s__('AlertManagement|Reported %{when}'), + reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'), }, + severityLabels: ALERTS_SEVERITY_LABELS, components: { GlAlert, + GlIcon, GlLoadingIcon, GlNewDropdown, GlNewDropdownItem, - timeAgoTooltip, + GlSprintf, GlTab, GlTabs, GlButton, + TimeAgoTooltip, }, mixins: [glFeatureFlagsMixin()], props: { @@ -79,6 +87,11 @@ export default { loading() { return this.$apollo.queries.alert.loading; }, + reportedAtMessage() { + return this.alert?.monitoringTool + ? this.$options.i18n.reportedAtWithTool + : this.$options.i18n.reportedAt; + }, showErrorMsg() { return this.errored && !this.isErrorDismissed; }, @@ -95,58 +108,79 @@ export default { <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError"> {{ $options.i18n.errorMsg }} </gl-alert> - <div v-if="loading"><gl-loading-icon size="lg" class="mt-3" /></div> - <div - v-if="alert" - class="gl-display-flex justify-content-end gl-border-b-1 gl-border-b-gray-200 gl-border-b-solid gl-p-4" - > - <gl-button - v-if="glFeatures.createIssueFromAlertEnabled" - data-testid="createIssueBtn" - :href="newIssuePath" - category="primary" - variant="success" + <div v-if="loading"><gl-loading-icon size="lg" class="gl-mt-5" /></div> + <div v-if="alert" class="alert-management-details"> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-px-1 gl-py-6 gl-border-b-1 gl-border-b-gray-200 gl-border-b-solid" > - {{ s__('AlertManagement|Create issue') }} - </gl-button> - </div> - <div - v-if="alert" - class="gl-display-flex gl-justify-content-space-between gl-align-items-center" - > - <h2 data-testid="title">{{ alert.title }}</h2> - <gl-new-dropdown right> - <gl-new-dropdown-item - v-for="(label, field) in $options.statuses" - :key="field" - data-testid="statusDropdownItem" - class="align-middle" - >{{ label }} - </gl-new-dropdown-item> - </gl-new-dropdown> + <div data-testid="alert-header"> + <div + class="gl-display-inline-flex gl-align-items-center gl-justify-content-space-between" + > + <gl-icon + class="gl-mr-3" + :size="12" + :name="`severity-${alert.severity.toLowerCase()}`" + :class="`icon-${alert.severity.toLowerCase()}`" + /> + <strong>{{ $options.severityLabels[alert.severity] }}</strong> + </div> + <span class="gl-shim-mx-2">•</span> + <gl-sprintf :message="reportedAtMessage"> + <template #when> + <time-ago-tooltip :time="alert.createdAt" /> + </template> + <template #tool>{{ alert.monitoringTool }}</template> + </gl-sprintf> + </div> + <gl-button + v-if="glFeatures.createIssueFromAlertEnabled" + data-testid="createIssueBtn" + :href="newIssuePath" + category="primary" + variant="success" + > + {{ s__('AlertManagement|Create issue') }} + </gl-button> + </div> + <div + v-if="alert" + class="gl-display-flex gl-justify-content-space-between gl-align-items-center" + > + <h2 data-testid="title">{{ alert.title }}</h2> + <gl-new-dropdown right> + <gl-new-dropdown-item + v-for="(label, field) in $options.statuses" + :key="field" + data-testid="statusDropdownItem" + class="gl-vertical-align-middle" + >{{ label }} + </gl-new-dropdown-item> + </gl-new-dropdown> + </div> + <gl-tabs v-if="alert" data-testid="alertDetailsTabs"> + <gl-tab data-testid="overviewTab" :title="$options.i18n.overviewTitle"> + <ul class="pl-4 mb-n1"> + <li v-if="alert.startedAt" class="my-2"> + <strong class="bold">{{ s__('AlertManagement|Start time') }}:</strong> + <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" /> + </li> + <li v-if="alert.eventCount" class="my-2"> + <strong class="bold">{{ s__('AlertManagement|Events') }}:</strong> + <span data-testid="eventCount">{{ alert.eventCount }}</span> + </li> + <li v-if="alert.monitoringTool" class="my-2"> + <strong class="bold">{{ s__('AlertManagement|Tool') }}:</strong> + <span data-testid="monitoringTool">{{ alert.monitoringTool }}</span> + </li> + <li v-if="alert.service" class="my-2"> + <strong class="bold">{{ s__('AlertManagement|Service') }}:</strong> + <span data-testid="service">{{ alert.service }}</span> + </li> + </ul> + </gl-tab> + <gl-tab data-testid="fullDetailsTab" :title="$options.i18n.fullAlertDetailsTitle" /> + </gl-tabs> </div> - <gl-tabs v-if="alert" data-testid="alertDetailsTabs"> - <gl-tab data-testid="overviewTab" :title="$options.i18n.overviewTitle"> - <ul class="pl-4 mb-n1"> - <li v-if="alert.startedAt" class="my-2"> - <strong class="bold">{{ s__('AlertManagement|Start time') }}:</strong> - <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" /> - </li> - <li v-if="alert.eventCount" class="my-2"> - <strong class="bold">{{ s__('AlertManagement|Events') }}:</strong> - <span data-testid="eventCount">{{ alert.eventCount }}</span> - </li> - <li v-if="alert.monitoringTool" class="my-2"> - <strong class="bold">{{ s__('AlertManagement|Tool') }}:</strong> - <span data-testid="monitoringTool">{{ alert.monitoringTool }}</span> - </li> - <li v-if="alert.service" class="my-2"> - <strong class="bold">{{ s__('AlertManagement|Service') }}:</strong> - <span data-testid="service">{{ alert.service }}</span> - </li> - </ul> - </gl-tab> - <gl-tab data-testid="fullDetailsTab" :title="$options.i18n.fullAlertDetailsTitle" /> - </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql index 81e95500e05..3e86df233d0 100644 --- a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql +++ b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql @@ -3,6 +3,7 @@ query alertDetails($fullPath: ID!, $alertId: String) { alertManagementAlerts(iid: $alertId) { nodes { iid + createdAt endedAt eventCount monitoringTool diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index f9a27d77498..199a16d8aad 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -11,32 +11,20 @@ export default { }, data() { return { - name: '', - type: modalTypes.blob, + entryName: '', + modalType: modalTypes.blob, path: '', }; }, computed: { ...mapState(['entries']), ...mapGetters('fileTemplates', ['templateTypes']), - entryName: { - get() { - if (this.type === modalTypes.rename) { - return this.name || this.path; - } - - return this.name || (this.path ? `${this.path}/` : ''); - }, - set(val) { - this.name = val.trim(); - }, - }, modalTitle() { const entry = this.entries[this.path]; - if (this.type === modalTypes.tree) { + if (this.modalType === modalTypes.tree) { return __('Create new directory'); - } else if (this.type === modalTypes.rename) { + } else if (this.modalType === modalTypes.rename) { return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file'); } @@ -45,16 +33,16 @@ export default { buttonLabel() { const entry = this.entries[this.path]; - if (this.type === modalTypes.tree) { + if (this.modalType === modalTypes.tree) { return __('Create directory'); - } else if (this.type === modalTypes.rename) { + } else if (this.modalType === modalTypes.rename) { return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file'); } return __('Create file'); }, isCreatingNewFile() { - return this.type === modalTypes.blob; + return this.modalType === modalTypes.blob; }, placeholder() { return this.isCreatingNewFile ? 'dir/file_name' : 'dir/'; @@ -63,8 +51,8 @@ export default { methods: { ...mapActions(['createTempEntry', 'renameEntry']), submitForm() { - if (this.type === modalTypes.rename) { - if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) { + if (this.modalType === modalTypes.rename) { + if (!this.entries[this.entryName]?.deleted) { flash( sprintf(s__('The name "%{name}" is already taken in this directory.'), { name: this.entryName, @@ -77,32 +65,32 @@ export default { ); } else { let parentPath = this.entryName.split('/'); - const entryName = parentPath.pop(); + const name = parentPath.pop(); parentPath = parentPath.join('/'); this.renameEntry({ path: this.path, - name: entryName, + name, parentPath, }); } } else { this.createTempEntry({ - name: this.name, - type: this.type, + name: this.entryName, + type: this.modalType, }); } }, createFromTemplate(template) { this.createTempEntry({ name: template.name, - type: this.type, + type: this.modalType, }); this.$refs.modal.toggle(); }, focusInput() { - const name = this.entries[this.entryName] ? this.entries[this.entryName].name : null; + const name = this.entries[this.entryName]?.name; const inputValue = this.$refs.fieldName.value; this.$refs.fieldName.focus(); @@ -112,19 +100,24 @@ export default { } }, resetData() { - this.name = ''; + this.entryName = ''; this.path = ''; - this.type = modalTypes.blob; + this.modalType = modalTypes.blob; }, open(type = modalTypes.blob, path = '') { - this.type = type; + this.modalType = type; this.path = path; + + if (this.modalType === modalTypes.rename) { + this.entryName = path; + } else { + this.entryName = path ? `${path}/` : ''; + } + this.$refs.modal.show(); // wait for modal to show first - this.$nextTick(() => { - this.focusInput(); - }); + this.$nextTick(() => this.focusInput()); }, close() { this.$refs.modal.hide(); @@ -150,7 +143,7 @@ export default { <div class="col-sm-10"> <input ref="fieldName" - v-model="entryName" + v-model.trim="entryName" type="text" class="form-control qa-full-file-path" :placeholder="placeholder" diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index dbd374f1e1c..0c2eafeed54 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -216,3 +216,14 @@ export const VARIABLE_TYPES = { custom: 'custom', text: 'text', }; + +/** + * The names of templating variables defined in the dashboard yml + * file are prefixed with a constant so that it doesn't collide with + * other URL params that the monitoring dashboard relies on for + * features like panel fullscreen etc. + * + * The prefix is added before it is appended to the URL and removed + * before passing the data to the backend. + */ +export const VARIABLE_PREFIX = 'var-'; diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index 8f8dc8127a0..12757bed588 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -1,3 +1,5 @@ +import { flatMap } from 'lodash'; +import { removePrefixFromLabels } from './utils'; import { NOT_IN_DB_PREFIX } from '../constants'; const metricsIdsInPanel = panel => @@ -110,16 +112,19 @@ export const filteredEnvironments = state => ); /** - * Maps an variables object to an array + * Maps an variables object to an array along with stripping + * the variable prefix. + * * @param {Object} variables - Custom variables provided by the user * @returns {Array} The custom variables array to be send to the API * in the format of [variable1, variable1_value] */ export const getCustomVariablesArray = state => - Object.entries(state.promVariables) - .flat() - .map(encodeURIComponent); + flatMap(state.promVariables, (val, key) => [ + encodeURIComponent(removePrefixFromLabels(key)), + encodeURIComponent(val), + ]); // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index a47e5f598f5..3dc88fa56fc 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -2,7 +2,7 @@ import { slugify } from '~/lib/utils/text_utility'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { NOT_IN_DB_PREFIX } from '../constants'; +import { NOT_IN_DB_PREFIX, VARIABLE_PREFIX } from '../constants'; export const gqClient = createGqClient( {}, @@ -229,3 +229,22 @@ export const normalizeQueryResult = timeSeries => { return normalizedResult; }; + +/** + * Variable labels are used as names for the dropdowns and also + * as URL params. Prefixing the name reduces the risk of + * collision with other URL params + * + * @param {String} label label for the template variable + * @returns {String} + */ +export const addPrefixToLabels = label => `${VARIABLE_PREFIX}${label}`; + +/** + * Before the templating variables are passed to the backend the + * prefix needs to be removed. + * + * @param {String} label label to remove prefix from + * @returns {String} + */ +export const removePrefixFromLabels = label => label.replace(VARIABLE_PREFIX, ''); diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js index af6d46cc786..0e49fc784fe 100644 --- a/app/assets/javascripts/monitoring/stores/variable_mapping.js +++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js @@ -1,4 +1,5 @@ import { isString } from 'lodash'; +import { addPrefixToLabels } from './utils'; import { VARIABLE_TYPES } from '../constants'; /** @@ -149,7 +150,7 @@ export const parseTemplatingVariables = ({ variables = {} } = {}) => if (parsedVar) { acc[key] = { ...parsedVar, - label: parsedVar.label || key, + label: addPrefixToLabels(parsedVar.label || key), }; } return acc; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 9e7f4b05420..4c714a684e9 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -1,10 +1,11 @@ -import { omit } from 'lodash'; +import { pickBy } from 'lodash'; import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility'; import { timeRangeParamNames, timeRangeFromParams, timeRangeToParams, } from '~/lib/utils/datetime_range'; +import { VARIABLE_PREFIX } from './constants'; /** * List of non time range url parameters @@ -122,19 +123,16 @@ export const timeRangeFromUrl = (search = window.location.search) => { }; /** - * Returns an array with user defined variables from the URL + * User-defined variables from the URL are extracted. The variables + * begin with a constant prefix so that it doesn't collide with + * other URL params. * - * @returns {Array} The custom variables defined by the - * user in the URL * @param {String} New URL + * @returns {Object} The custom variables defined by the user in the URL */ -export const promCustomVariablesFromUrl = (search = window.location.search) => { - const params = queryToObject(search); - const paramsToRemove = timeRangeParamNames.concat(dashboardParams); - - return omit(params, paramsToRemove); -}; +export const promCustomVariablesFromUrl = (search = window.location.search) => + pickBy(queryToObject(search), (val, key) => key.startsWith(VARIABLE_PREFIX)); /** * Returns a URL with no time range based on the current URL. diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue deleted file mode 100644 index 7ed4da84120..00000000000 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue +++ /dev/null @@ -1,38 +0,0 @@ -<script> -/* This is a re-usable vue component for rendering a user avatar svg (typically - for a blank state). It will receive styles comparable to the user avatar, - but no image is loaded, it isn't wrapped in a link, and tooltips aren't supported. - The svg and avatar size can be configured by props passed to this component. - - Sample configuration: - - <user-avatar-svg - :svg="potentialApproverSvg" - :size="20" - /> - -*/ - -export default { - props: { - svg: { - type: String, - required: true, - }, - size: { - type: Number, - required: false, - default: 20, - }, - }, - computed: { - avatarSizeClass() { - return `s${this.size}`; - }, - }, -}; -</script> - -<template> - <svg :class="avatarSizeClass" :height="size" :width="size" v-html="svg" /> -</template> diff --git a/app/assets/stylesheets/pages/alerts_list.scss b/app/assets/stylesheets/pages/alerts_list.scss index 5974f97b728..7f817d10ffe 100644 --- a/app/assets/stylesheets/pages/alerts_list.scss +++ b/app/assets/stylesheets/pages/alerts_list.scss @@ -1,4 +1,5 @@ -.alert-management-list { +.alert-management-list, +.alert-management-details { .icon-critical { color: $red-800; } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index a8223a8ff56..8cf5c533f1f 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -97,6 +97,11 @@ padding-top: 16px; } +.gl-shim-mx-2 { + margin-left: 4px; + margin-right: 4px; +} + .gl-text-purple { color: $purple; } .gl-text-gray-800 { color: $gray-800; } .gl-bg-purple-light { background-color: $purple-light; } diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb index 58715fda152..f177b67b079 100644 --- a/app/controllers/concerns/metrics_dashboard.rb +++ b/app/controllers/concerns/metrics_dashboard.rb @@ -38,7 +38,6 @@ module MetricsDashboard dashboard_finder .find_all_paths(project_for_dashboard) .map(&method(:amend_dashboard)) - .sort_by { |dashboard| [dashboard[:starred] ? 0 : 1, dashboard[:display_name].downcase] } end def amend_dashboard(dashboard) diff --git a/app/graphql/mutations/design_management/base.rb b/app/graphql/mutations/design_management/base.rb new file mode 100644 index 00000000000..918e5709b94 --- /dev/null +++ b/app/graphql/mutations/design_management/base.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Mutations + module DesignManagement + class Base < ::Mutations::BaseMutation + include Mutations::ResolvesIssuable + + argument :project_path, GraphQL::ID_TYPE, + required: true, + description: "The project where the issue is to upload designs for" + + argument :iid, GraphQL::ID_TYPE, + required: true, + description: "The iid of the issue to modify designs for" + + private + + def find_object(project_path:, iid:) + resolve_issuable(type: :issue, parent_path: project_path, iid: iid) + end + end + end +end diff --git a/app/graphql/mutations/design_management/delete.rb b/app/graphql/mutations/design_management/delete.rb new file mode 100644 index 00000000000..d2ef2c9bcca --- /dev/null +++ b/app/graphql/mutations/design_management/delete.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Mutations + module DesignManagement + class Delete < Base + Errors = ::Gitlab::Graphql::Errors + + graphql_name "DesignManagementDelete" + + argument :filenames, [GraphQL::STRING_TYPE], + required: true, + description: "The filenames of the designs to delete", + prepare: ->(names, _ctx) do + names.presence || (raise Errors::ArgumentError, 'no filenames') + end + + field :version, Types::DesignManagement::VersionType, + null: true, # null on error + description: 'The new version in which the designs are deleted' + + authorize :destroy_design + + def resolve(project_path:, iid:, filenames:) + issue = authorized_find!(project_path: project_path, iid: iid) + project = issue.project + designs = resolve_designs(issue, filenames) + + result = ::DesignManagement::DeleteDesignsService + .new(project, current_user, issue: issue, designs: designs) + .execute + + { + version: result[:version], + errors: Array.wrap(result[:message]) + } + end + + private + + # Here we check that: + # * we find exactly as many designs as filenames + def resolve_designs(issue, filenames) + designs = issue.design_collection.designs_by_filename(filenames) + + validate_all_were_found!(designs, filenames) + + designs + end + + def validate_all_were_found!(designs, filenames) + found_filenames = designs.map(&:filename) + missing = filenames.difference(found_filenames) + + if missing.present? + raise Errors::ArgumentError, <<~MSG + Not all the designs you named currently exist. + The following filenames were not found: + #{missing.join(', ')} + + They may have already been deleted. + MSG + end + end + end + end +end diff --git a/app/graphql/mutations/design_management/upload.rb b/app/graphql/mutations/design_management/upload.rb new file mode 100644 index 00000000000..1ed7f8e49e6 --- /dev/null +++ b/app/graphql/mutations/design_management/upload.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Mutations + module DesignManagement + class Upload < Base + graphql_name "DesignManagementUpload" + + argument :files, [ApolloUploadServer::Upload], + required: true, + description: "The files to upload" + + authorize :create_design + + field :designs, [Types::DesignManagement::DesignType], + null: false, + description: "The designs that were uploaded by the mutation" + + field :skipped_designs, [Types::DesignManagement::DesignType], + null: false, + description: "Any designs that were skipped from the upload due to there " \ + "being no change to their content since their last version" + + def resolve(project_path:, iid:, files:) + issue = authorized_find!(project_path: project_path, iid: iid) + project = issue.project + + result = ::DesignManagement::SaveDesignsService.new(project, current_user, issue: issue, files: files) + .execute + + { + designs: Array.wrap(result[:designs]), + skipped_designs: Array.wrap(result[:skipped_designs]), + errors: Array.wrap(result[:message]) + } + end + end + end +end diff --git a/app/graphql/resolvers/design_management/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/design_at_version_resolver.rb new file mode 100644 index 00000000000..fd9b349f974 --- /dev/null +++ b/app/graphql/resolvers/design_management/design_at_version_resolver.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + class DesignAtVersionResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::DesignManagement::DesignAtVersionType, null: false + + authorize :read_design + + argument :id, GraphQL::ID_TYPE, + required: true, + description: 'The Global ID of the design at this version' + + def resolve(id:) + authorized_find!(id: id) + end + + def find_object(id:) + dav = GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::DesignAtVersion) + return unless consistent?(dav) + + dav + end + + def self.single + self + end + + private + + # If this resolver is mounted on something that has an issue + # (such as design collection for instance), then we should check + # that the DesignAtVersion as found by its ID does in fact belong + # to this issue. + def consistent?(dav) + issue.nil? || (dav&.design&.issue_id == issue.id) + end + + def issue + object&.issue + end + end + end +end diff --git a/app/graphql/resolvers/design_management/design_resolver.rb b/app/graphql/resolvers/design_management/design_resolver.rb new file mode 100644 index 00000000000..05bdbbbe407 --- /dev/null +++ b/app/graphql/resolvers/design_management/design_resolver.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + class DesignResolver < BaseResolver + argument :id, GraphQL::ID_TYPE, + required: false, + description: 'Find a design by its ID' + + argument :filename, GraphQL::STRING_TYPE, + required: false, + description: 'Find a design by its filename' + + def resolve(filename: nil, id: nil) + params = parse_args(filename, id) + + build_finder(params).execute.first + end + + def self.single + self + end + + private + + def issue + object.issue + end + + def build_finder(params) + ::DesignManagement::DesignsFinder.new(issue, current_user, params) + end + + def error(msg) + raise ::Gitlab::Graphql::Errors::ArgumentError, msg + end + + def parse_args(filename, id) + provided = [filename, id].map(&:present?) + + if provided.none? + error('one of id or filename must be passed') + elsif provided.all? + error('only one of id or filename may be passed') + elsif filename.present? + { filenames: [filename] } + else + { ids: [parse_gid(id)] } + end + end + + def parse_gid(gid) + GitlabSchema.parse_gid(gid, expected_type: ::DesignManagement::Design).model_id + end + end + end +end diff --git a/app/graphql/resolvers/design_management/designs_resolver.rb b/app/graphql/resolvers/design_management/designs_resolver.rb new file mode 100644 index 00000000000..81f94d5cb30 --- /dev/null +++ b/app/graphql/resolvers/design_management/designs_resolver.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + class DesignsResolver < BaseResolver + argument :ids, + [GraphQL::ID_TYPE], + required: false, + description: 'Filters designs by their ID' + argument :filenames, + [GraphQL::STRING_TYPE], + required: false, + description: 'Filters designs by their filename' + argument :at_version, + GraphQL::ID_TYPE, + required: false, + description: 'Filters designs to only those that existed at the version. ' \ + 'If argument is omitted or nil then all designs will reflect the latest version' + + def self.single + ::Resolvers::DesignManagement::DesignResolver + end + + def resolve(ids: nil, filenames: nil, at_version: nil) + ::DesignManagement::DesignsFinder.new( + issue, + current_user, + ids: design_ids(ids), + filenames: filenames, + visible_at_version: version(at_version), + order: :id + ).execute + end + + private + + def version(at_version) + GitlabSchema.object_from_id(at_version)&.sync if at_version + end + + def design_ids(ids) + ids&.map { |id| GlobalID.parse(id).model_id } + end + + def issue + object.issue + end + end + end +end diff --git a/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb new file mode 100644 index 00000000000..03f7908780c --- /dev/null +++ b/app/graphql/resolvers/design_management/version/design_at_version_resolver.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + module Version + # Resolver for a DesignAtVersion object given an implicit version context + class DesignAtVersionResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::DesignManagement::DesignAtVersionType, null: true + + authorize :read_design + + argument :id, GraphQL::ID_TYPE, + required: false, + as: :design_at_version_id, + description: 'The ID of the DesignAtVersion' + argument :design_id, GraphQL::ID_TYPE, + required: false, + description: 'The ID of a specific design' + argument :filename, GraphQL::STRING_TYPE, + required: false, + description: 'The filename of a specific design' + + def self.single + self + end + + def resolve(design_id: nil, filename: nil, design_at_version_id: nil) + validate_arguments(design_id, filename, design_at_version_id) + + return unless Ability.allowed?(current_user, :read_design, issue) + return specific_design_at_version(design_at_version_id) if design_at_version_id + + find(design_id, filename).map { |d| make(d) }.first + end + + private + + def validate_arguments(design_id, filename, design_at_version_id) + args = { filename: filename, id: design_at_version_id, design_id: design_id } + passed = args.compact.keys + + return if passed.size == 1 + + msg = "Exactly one of #{args.keys.join(', ')} expected, got #{passed}" + + raise Gitlab::Graphql::Errors::ArgumentError, msg + end + + def specific_design_at_version(id) + dav = GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::DesignAtVersion) + return unless consistent?(dav) + + dav + end + + # Test that the DAV found by ID actually belongs on this version, and + # that it is visible at this version. + def consistent?(dav) + return false unless dav.present? + + dav.design.issue_id == issue.id && + dav.version.id == version.id && + dav.design.visible_in?(version) + end + + def find(id, filename) + ids = [parse_design_id(id).model_id] if id + filenames = [filename] if filename + + ::DesignManagement::DesignsFinder + .new(issue, current_user, ids: ids, filenames: filenames, visible_at_version: version) + .execute + end + + def parse_design_id(id) + GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Design) + end + + def issue + version.issue + end + + def version + object + end + + def make(design) + ::DesignManagement::DesignAtVersion.new(design: design, version: version) + end + end + end + end +end diff --git a/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb b/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb new file mode 100644 index 00000000000..5ccb2f3e311 --- /dev/null +++ b/app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + module Version + # Resolver for DesignAtVersion objects given an implicit version context + class DesignsAtVersionResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::DesignManagement::DesignAtVersionType, null: true + + authorize :read_design + + argument :ids, + [GraphQL::ID_TYPE], + required: false, + description: 'Filters designs by their ID' + argument :filenames, + [GraphQL::STRING_TYPE], + required: false, + description: 'Filters designs by their filename' + + def self.single + ::Resolvers::DesignManagement::Version::DesignAtVersionResolver + end + + def resolve(ids: nil, filenames: nil) + find(ids, filenames).execute.map { |d| make(d) } + end + + private + + def find(ids, filenames) + ids = ids&.map { |id| parse_design_id(id).model_id } + + ::DesignManagement::DesignsFinder.new(issue, current_user, + ids: ids, + filenames: filenames, + visible_at_version: version) + end + + def parse_design_id(id) + GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Design) + end + + def issue + version.issue + end + + def version + object + end + + def make(design) + ::DesignManagement::DesignAtVersion.new(design: design, version: version) + end + end + end + end +end diff --git a/app/graphql/resolvers/design_management/version_in_collection_resolver.rb b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb new file mode 100644 index 00000000000..9e729172881 --- /dev/null +++ b/app/graphql/resolvers/design_management/version_in_collection_resolver.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + class VersionInCollectionResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::DesignManagement::VersionType, null: true + + authorize :read_design + + alias_method :collection, :object + + argument :sha, GraphQL::STRING_TYPE, + required: false, + description: "The SHA256 of a specific version" + argument :id, GraphQL::ID_TYPE, + required: false, + description: 'The Global ID of the version' + + def resolve(id: nil, sha: nil) + check_args(id, sha) + + gid = GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Version) if id + + ::DesignManagement::VersionsFinder + .new(collection, current_user, sha: sha, version_id: gid&.model_id) + .execute + .first + end + + def self.single + self + end + + private + + def check_args(id, sha) + return if id.present? || sha.present? + + raise ::Gitlab::Graphql::Errors::ArgumentError, 'one of id or sha is required' + end + end + end +end diff --git a/app/graphql/resolvers/design_management/version_resolver.rb b/app/graphql/resolvers/design_management/version_resolver.rb new file mode 100644 index 00000000000..b0e0843e6c8 --- /dev/null +++ b/app/graphql/resolvers/design_management/version_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + class VersionResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::DesignManagement::VersionType, null: true + + authorize :read_design + + argument :id, GraphQL::ID_TYPE, + required: true, + description: 'The Global ID of the version' + + def resolve(id:) + authorized_find!(id: id) + end + + def find_object(id:) + GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::Version) + end + end + end +end diff --git a/app/graphql/resolvers/design_management/versions_resolver.rb b/app/graphql/resolvers/design_management/versions_resolver.rb new file mode 100644 index 00000000000..a62258dad5c --- /dev/null +++ b/app/graphql/resolvers/design_management/versions_resolver.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Resolvers + module DesignManagement + class VersionsResolver < BaseResolver + type Types::DesignManagement::VersionType.connection_type, null: false + + alias_method :design_or_collection, :object + + argument :earlier_or_equal_to_sha, GraphQL::STRING_TYPE, + as: :sha, + required: false, + description: 'The SHA256 of the most recent acceptable version' + + argument :earlier_or_equal_to_id, GraphQL::ID_TYPE, + as: :id, + required: false, + description: 'The Global ID of the most recent acceptable version' + + # This resolver has a custom singular resolver + def self.single + ::Resolvers::DesignManagement::VersionInCollectionResolver + end + + def resolve(parent: nil, id: nil, sha: nil) + version = cutoff(parent, id, sha) + + raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, 'cutoff not found' unless version.present? + + if version == :unconstrained + find + else + find(earlier_or_equal_to: version) + end + end + + private + + # Find the most recent version that the client will accept + def cutoff(parent, id, sha) + if sha.present? || id.present? + specific_version(id, sha) + elsif at_version = at_version_arg(parent) + by_id(at_version) + else + :unconstrained + end + end + + def specific_version(id, sha) + gid = GitlabSchema.parse_gid(id, expected_type: ::DesignManagement::Version) if id + find(sha: sha, version_id: gid&.model_id).first + end + + def find(**params) + ::DesignManagement::VersionsFinder + .new(design_or_collection, current_user, params) + .execute + end + + def by_id(id) + GitlabSchema.object_from_id(id, expected_type: ::DesignManagement::Version).sync + end + + # Find an `at_version` argument passed to a parent node. + # + # If one is found, then a design collection further up the AST + # has been filtered to reflect designs at that version, and so + # for consistency we should only present versions up to the given + # version here. + def at_version_arg(parent) + ::Gitlab::Graphql::FindArgumentInParent.find(parent, :at_version, limit_depth: 4) + end + end + end +end diff --git a/app/graphql/types/design_management/design_at_version_type.rb b/app/graphql/types/design_management/design_at_version_type.rb new file mode 100644 index 00000000000..343d4cf4ff4 --- /dev/null +++ b/app/graphql/types/design_management/design_at_version_type.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Types + module DesignManagement + class DesignAtVersionType < BaseObject + graphql_name 'DesignAtVersion' + + description 'A design pinned to a specific version. ' \ + 'The image field reflects the design as of the associated version.' + + authorize :read_design + + delegate :design, :version, to: :object + delegate :issue, :filename, :full_path, :diff_refs, to: :design + + implements ::Types::DesignManagement::DesignFields + + field :version, + Types::DesignManagement::VersionType, + null: false, + description: 'The version this design-at-versions is pinned to' + + field :design, + Types::DesignManagement::DesignType, + null: false, + description: 'The underlying design.' + + def cached_stateful_version(_parent) + version + end + + def notes_count + design.user_notes_count + end + end + end +end diff --git a/app/graphql/types/design_management/design_collection_type.rb b/app/graphql/types/design_management/design_collection_type.rb new file mode 100644 index 00000000000..194910831c6 --- /dev/null +++ b/app/graphql/types/design_management/design_collection_type.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Types + module DesignManagement + class DesignCollectionType < BaseObject + graphql_name 'DesignCollection' + description 'A collection of designs.' + + authorize :read_design + + field :project, Types::ProjectType, null: false, + description: 'Project associated with the design collection' + field :issue, Types::IssueType, null: false, + description: 'Issue associated with the design collection' + + field :designs, + Types::DesignManagement::DesignType.connection_type, + null: false, + resolver: Resolvers::DesignManagement::DesignsResolver, + description: 'All designs for the design collection', + complexity: 5 + + field :versions, + Types::DesignManagement::VersionType.connection_type, + resolver: Resolvers::DesignManagement::VersionsResolver, + description: 'All versions related to all designs, ordered newest first' + + field :version, + Types::DesignManagement::VersionType, + resolver: Resolvers::DesignManagement::VersionsResolver.single, + description: 'A specific version' + + field :design_at_version, ::Types::DesignManagement::DesignAtVersionType, + null: true, + resolver: ::Resolvers::DesignManagement::DesignAtVersionResolver, + description: 'Find a design as of a version' + + field :design, ::Types::DesignManagement::DesignType, + null: true, + resolver: ::Resolvers::DesignManagement::DesignResolver, + description: 'Find a specific design' + end + end +end diff --git a/app/graphql/types/design_management/design_fields.rb b/app/graphql/types/design_management/design_fields.rb new file mode 100644 index 00000000000..b03b3927392 --- /dev/null +++ b/app/graphql/types/design_management/design_fields.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Types + module DesignManagement + module DesignFields + include BaseInterface + + field_class Types::BaseField + + field :id, GraphQL::ID_TYPE, description: 'The ID of this design', null: false + field :project, Types::ProjectType, null: false, description: 'The project the design belongs to' + field :issue, Types::IssueType, null: false, description: 'The issue the design belongs to' + field :filename, GraphQL::STRING_TYPE, null: false, description: 'The filename of the design' + field :full_path, GraphQL::STRING_TYPE, null: false, description: 'The full path to the design file' + field :image, GraphQL::STRING_TYPE, null: false, extras: [:parent], description: 'The URL of the full-sized image' + field :image_v432x230, GraphQL::STRING_TYPE, null: true, extras: [:parent], + description: 'The URL of the design resized to fit within the bounds of 432x230. ' \ + 'This will be `null` if the image has not been generated' + field :diff_refs, Types::DiffRefsType, + null: false, + calls_gitaly: true, + extras: [:parent], + description: 'The diff refs for this design' + field :event, Types::DesignManagement::DesignVersionEventEnum, + null: false, + extras: [:parent], + description: 'How this design was changed in the current version' + field :notes_count, + GraphQL::INT_TYPE, + null: false, + method: :user_notes_count, + description: 'The total count of user-created notes for this design' + + def diff_refs(parent:) + version = cached_stateful_version(parent) + version.diff_refs + end + + def image(parent:) + sha = cached_stateful_version(parent).sha + + Gitlab::UrlBuilder.build(design, ref: sha) + end + + def image_v432x230(parent:) + version = cached_stateful_version(parent) + action = design.actions.up_to_version(version).most_recent.first + + # A `nil` return value indicates that the image has not been processed + return unless action.image_v432x230.file + + Gitlab::UrlBuilder.build(design, ref: version.sha, size: :v432x230) + end + + def event(parent:) + version = cached_stateful_version(parent) + + action = cached_actions_for_version(version)[design.id] + + action&.event || ::Types::DesignManagement::DesignVersionEventEnum::NONE + end + + def cached_actions_for_version(version) + Gitlab::SafeRequestStore.fetch(['DesignFields', 'actions_for_version', version.id]) do + version.actions.to_h { |dv| [dv.design_id, dv] } + end + end + + def project + ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Project, design.project_id).find + end + + def issue + ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Issue, design.issue_id).find + end + end + end +end diff --git a/app/graphql/types/design_management/design_type.rb b/app/graphql/types/design_management/design_type.rb new file mode 100644 index 00000000000..3c84dc151bd --- /dev/null +++ b/app/graphql/types/design_management/design_type.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Types + module DesignManagement + class DesignType < BaseObject + graphql_name 'Design' + description 'A single design' + + authorize :read_design + + alias_method :design, :object + + implements(Types::Notes::NoteableType) + implements(Types::DesignManagement::DesignFields) + + field :versions, + Types::DesignManagement::VersionType.connection_type, + resolver: Resolvers::DesignManagement::VersionsResolver, + description: "All versions related to this design ordered newest first", + extras: [:parent] + + # Returns a `DesignManagement::Version` for this query based on the + # `atVersion` argument passed to a parent node if present, or otherwise + # the most recent `Version` for the issue. + def cached_stateful_version(parent_node) + version_gid = Gitlab::Graphql::FindArgumentInParent.find(parent_node, :at_version) + + # Caching is scoped to an `issue_id` to allow us to cache the + # most recent `Version` for an issue + Gitlab::SafeRequestStore.fetch([request_cache_base_key, 'stateful_version', object.issue_id, version_gid]) do + if version_gid + GitlabSchema.object_from_id(version_gid)&.sync + else + object.issue.design_versions.most_recent + end + end + end + + def request_cache_base_key + self.class.name + end + end + end +end diff --git a/app/graphql/types/design_management/design_version_event_enum.rb b/app/graphql/types/design_management/design_version_event_enum.rb new file mode 100644 index 00000000000..ea4bc1ffbfa --- /dev/null +++ b/app/graphql/types/design_management/design_version_event_enum.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module DesignManagement + class DesignVersionEventEnum < BaseEnum + graphql_name 'DesignVersionEvent' + description 'Mutation event of a design within a version' + + NONE = 'NONE' + + value NONE, 'No change' + + ::DesignManagement::Action.events.keys.each do |event_name| + value event_name.upcase, value: event_name, description: "A #{event_name} event" + end + end + end +end diff --git a/app/graphql/types/design_management/version_type.rb b/app/graphql/types/design_management/version_type.rb new file mode 100644 index 00000000000..c774f5d1bdf --- /dev/null +++ b/app/graphql/types/design_management/version_type.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Types + module DesignManagement + class VersionType < ::Types::BaseObject + # Just `Version` might be a bit to general to expose globally so adding + # a `Design` prefix to specify the class exposed in GraphQL + graphql_name 'DesignVersion' + + description 'A specific version in which designs were added, modified or deleted' + + authorize :read_design + + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the design version' + field :sha, GraphQL::ID_TYPE, null: false, + description: 'SHA of the design version' + + field :designs, + ::Types::DesignManagement::DesignType.connection_type, + null: false, + description: 'All designs that were changed in the version' + + field :designs_at_version, + ::Types::DesignManagement::DesignAtVersionType.connection_type, + null: false, + description: 'All designs that are visible at this version, as of this version', + resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver + + field :design_at_version, + ::Types::DesignManagement::DesignAtVersionType, + null: false, + description: 'A particular design as of this version, provided it is visible at this version', + resolver: ::Resolvers::DesignManagement::Version::DesignsAtVersionResolver.single + end + end +end diff --git a/app/graphql/types/design_management_type.rb b/app/graphql/types/design_management_type.rb new file mode 100644 index 00000000000..ec85b8a0c1f --- /dev/null +++ b/app/graphql/types/design_management_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# rubocop: disable Graphql/AuthorizeTypes +module Types + class DesignManagementType < BaseObject + graphql_name 'DesignManagement' + + field :version, ::Types::DesignManagement::VersionType, + null: true, + resolver: ::Resolvers::DesignManagement::VersionResolver, + description: 'Find a version' + + field :design_at_version, ::Types::DesignManagement::DesignAtVersionType, + null: true, + resolver: ::Resolvers::DesignManagement::DesignAtVersionResolver, + description: 'Find a design as of a version' + end +end diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 11850e5865f..73219ca9e1e 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -85,6 +85,14 @@ module Types field :task_completion_status, Types::TaskCompletionStatus, null: false, description: 'Task completion status of the issue' + + field :designs, Types::DesignManagement::DesignCollectionType, null: true, + method: :design_collection, + deprecated: { reason: 'Use `designCollection`', milestone: '12.2' }, + description: 'The designs associated with this issue' + + field :design_collection, Types::DesignManagement::DesignCollectionType, null: true, + description: 'Collection of design images associated with this issue' end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index b18a3968a03..6e1bc962cd2 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -42,6 +42,8 @@ module Types mount_mutation Mutations::Snippets::Create mount_mutation Mutations::Snippets::MarkAsSpam mount_mutation Mutations::JiraImport::Start + mount_mutation Mutations::DesignManagement::Upload, calls_gitaly: true + mount_mutation Mutations::DesignManagement::Delete, calls_gitaly: true end end diff --git a/app/graphql/types/notes/noteable_type.rb b/app/graphql/types/notes/noteable_type.rb index 2ac66452841..187c9109f8c 100644 --- a/app/graphql/types/notes/noteable_type.rb +++ b/app/graphql/types/notes/noteable_type.rb @@ -17,6 +17,8 @@ module Types Types::MergeRequestType when Snippet Types::SnippetType + when ::DesignManagement::Design + Types::DesignManagement::DesignType else raise "Unknown GraphQL type for #{object}" end @@ -25,5 +27,3 @@ module Types end end end - -Types::Notes::NoteableType.extend_if_ee('::EE::Types::Notes::NoteableType') diff --git a/app/graphql/types/permission_types/issue.rb b/app/graphql/types/permission_types/issue.rb index e26c5950e73..94e1bffd685 100644 --- a/app/graphql/types/permission_types/issue.rb +++ b/app/graphql/types/permission_types/issue.rb @@ -6,11 +6,9 @@ module Types description 'Check permissions for the current user on a issue' graphql_name 'IssuePermissions' - abilities :read_issue, :admin_issue, - :update_issue, :create_note, - :reopen_issue + abilities :read_issue, :admin_issue, :update_issue, :reopen_issue, + :read_design, :create_design, :destroy_design, + :create_note end end end - -Types::PermissionTypes::Issue.prepend_if_ee('::EE::Types::PermissionTypes::Issue') diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb index f773fce0c63..5747e63d195 100644 --- a/app/graphql/types/permission_types/project.rb +++ b/app/graphql/types/permission_types/project.rb @@ -17,7 +17,7 @@ module Types :admin_wiki, :admin_project, :update_pages, :admin_remote_mirror, :create_label, :update_wiki, :destroy_wiki, :create_pages, :destroy_pages, :read_pages_content, :admin_operations, - :read_merge_request + :read_merge_request, :read_design, :create_design, :destroy_design permission_field :create_snippet @@ -27,5 +27,3 @@ module Types end end end - -Types::PermissionTypes::Project.prepend_if_ee('EE::Types::PermissionTypes::Project') diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index e0479c8227b..70cdcb62bc6 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -4,6 +4,9 @@ module Types class QueryType < ::Types::BaseObject graphql_name 'Query' + # The design management context object needs to implement #issue + DesignManagementObject = Struct.new(:issue) + field :project, Types::ProjectType, null: true, resolver: Resolvers::ProjectResolver, @@ -40,9 +43,17 @@ module Types resolver: Resolvers::SnippetsResolver, description: 'Find Snippets visible to the current user' + field :design_management, Types::DesignManagementType, + null: false, + description: 'Fields related to design management' + field :echo, GraphQL::STRING_TYPE, null: false, description: 'Text to echo back', resolver: Resolvers::EchoResolver + + def design_management + DesignManagementObject.new(nil) + end end end diff --git a/app/graphql/types/todo_target_enum.rb b/app/graphql/types/todo_target_enum.rb index 8358a86b35c..a377c3aafdc 100644 --- a/app/graphql/types/todo_target_enum.rb +++ b/app/graphql/types/todo_target_enum.rb @@ -5,6 +5,7 @@ module Types value 'COMMIT', value: 'Commit', description: 'A Commit' value 'ISSUE', value: 'Issue', description: 'An Issue' value 'MERGEREQUEST', value: 'MergeRequest', description: 'A MergeRequest' + value 'DESIGN', value: 'DesignManagement::Design', description: 'A Design' end end diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 52b81153c43..8747430a909 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -9,13 +9,6 @@ module IssuesHelper classes.join(' ') end - # Returns an OpenStruct object suitable for use by <tt>options_from_collection_for_select</tt> - # to allow filtering issues by an unassigned User or Milestone - def unassigned_filter - # Milestone uses :title, Issue uses :name - OpenStruct.new(id: 0, title: 'None (backlog)', name: 'Unassigned') - end - def url_for_issue(issue_iid, project = @project, options = {}) return '' if project.nil? diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb index 8590bc7f4eb..cea3c7d119c 100644 --- a/app/models/concerns/diff_positionable_note.rb +++ b/app/models/concerns/diff_positionable_note.rb @@ -23,6 +23,8 @@ module DiffPositionableNote if new_position.is_a?(Hash) new_position = new_position.with_indifferent_access new_position = Gitlab::Diff::Position.new(new_position) + elsif !new_position.is_a?(Gitlab::Diff::Position) + new_position = nil end return if new_position == read_attribute(meth) diff --git a/changelogs/unreleased/214582-revert-sort-order-change-BE.yml b/changelogs/unreleased/214582-revert-sort-order-change-BE.yml new file mode 100644 index 00000000000..38757fbde1b --- /dev/null +++ b/changelogs/unreleased/214582-revert-sort-order-change-BE.yml @@ -0,0 +1,5 @@ +--- +title: Restore original sort order of the metrics dashboard select list +merge_request: 31859 +author: +type: fixed diff --git a/changelogs/unreleased/216597-drop-uss-pss-env-var.yml b/changelogs/unreleased/216597-drop-uss-pss-env-var.yml new file mode 100644 index 00000000000..04be98dfc4d --- /dev/null +++ b/changelogs/unreleased/216597-drop-uss-pss-env-var.yml @@ -0,0 +1,5 @@ +--- +title: Ruby metrics now include USS and PSS memory readings +merge_request: 31707 +author: +type: added diff --git a/changelogs/unreleased/24525-ide-renaming-issue.yml b/changelogs/unreleased/24525-ide-renaming-issue.yml new file mode 100644 index 00000000000..d4af52bbe25 --- /dev/null +++ b/changelogs/unreleased/24525-ide-renaming-issue.yml @@ -0,0 +1,5 @@ +--- +title: Fix error renaming files using web IDE +merge_request: 30969 +author: +type: fixed diff --git a/changelogs/unreleased/dbodicherla-add-prefix-for-templating-variables.yml b/changelogs/unreleased/dbodicherla-add-prefix-for-templating-variables.yml new file mode 100644 index 00000000000..027868220ee --- /dev/null +++ b/changelogs/unreleased/dbodicherla-add-prefix-for-templating-variables.yml @@ -0,0 +1,5 @@ +--- +title: Add prefix to template variables in URL in the monitoring dashboard +merge_request: 31690 +author: +type: changed diff --git a/changelogs/unreleased/fj-fix-templates-endpoint-when-project-has-dots.yml b/changelogs/unreleased/fj-fix-templates-endpoint-when-project-has-dots.yml new file mode 100644 index 00000000000..3de4c3eb6de --- /dev/null +++ b/changelogs/unreleased/fj-fix-templates-endpoint-when-project-has-dots.yml @@ -0,0 +1,5 @@ +--- +title: Fix templates API endpoint when project name has dots +merge_request: 31758 +author: +type: fixed diff --git a/doc/administration/monitoring/prometheus/gitlab_metrics.md b/doc/administration/monitoring/prometheus/gitlab_metrics.md index cdd4d3937bc..f725db9a039 100644 --- a/doc/administration/monitoring/prometheus/gitlab_metrics.md +++ b/doc/administration/monitoring/prometheus/gitlab_metrics.md @@ -178,16 +178,18 @@ The following metrics are available: Some basic Ruby runtime metrics are available: -| Metric | Type | Since | Description | -|:------------------------------------ |:--------- |:----- |:----------- | -| `ruby_gc_duration_seconds` | Counter | 11.1 | Time spent by Ruby in GC | -| `ruby_gc_stat_...` | Gauge | 11.1 | Various metrics from [GC.stat](https://ruby-doc.org/core-2.6.5/GC.html#method-c-stat) | -| `ruby_file_descriptors` | Gauge | 11.1 | File descriptors per process | -| `ruby_sampler_duration_seconds` | Counter | 11.1 | Time spent collecting stats | -| `ruby_process_cpu_seconds_total` | Gauge | 12.0 | Total amount of CPU time per process | -| `ruby_process_max_fds` | Gauge | 12.0 | Maximum number of open file descriptors per process | -| `ruby_process_resident_memory_bytes` | Gauge | 12.0 | Memory usage by process | -| `ruby_process_start_time_seconds` | Gauge | 12.0 | UNIX timestamp of process start time | +| Metric | Type | Since | Description | +|:---------------------------------------- |:--------- |:----- |:----------- | +| `ruby_gc_duration_seconds` | Counter | 11.1 | Time spent by Ruby in GC | +| `ruby_gc_stat_...` | Gauge | 11.1 | Various metrics from [GC.stat](https://ruby-doc.org/core-2.6.5/GC.html#method-c-stat) | +| `ruby_file_descriptors` | Gauge | 11.1 | File descriptors per process | +| `ruby_sampler_duration_seconds` | Counter | 11.1 | Time spent collecting stats | +| `ruby_process_cpu_seconds_total` | Gauge | 12.0 | Total amount of CPU time per process | +| `ruby_process_max_fds` | Gauge | 12.0 | Maximum number of open file descriptors per process | +| `ruby_process_resident_memory_bytes` | Gauge | 12.0 | Memory usage by process (RSS/Resident Set Size) | +| `ruby_process_unique_memory_bytes` | Gauge | 13.0 | Memory usage by process (USS/Unique Set Size) | +| `ruby_process_proportional_memory_bytes` | Gauge | 13.0 | Memory usage by process (PSS/Proportional Set Size) | +| `ruby_process_start_time_seconds` | Gauge | 12.0 | UNIX timestamp of process start time | ## Unicorn Metrics diff --git a/doc/development/ee_features.md b/doc/development/ee_features.md index e924ed934d6..3951b0516e8 100644 --- a/doc/development/ee_features.md +++ b/doc/development/ee_features.md @@ -514,7 +514,7 @@ do that, so we'll follow regular object-oriented practices that we define the interface first here. For example, suppose we have a few more optional parameters for EE. We can move the -paramters out of the `Grape::API` class to a helper module, so we can inject it +parameters out of the `Grape::API` class to a helper module, so we can inject it before it would be used in the class. ```ruby diff --git a/doc/user/analytics/img/repository_analytics_v13_0.png b/doc/user/analytics/img/repository_analytics_v13_0.png Binary files differnew file mode 100644 index 00000000000..b70b63a6239 --- /dev/null +++ b/doc/user/analytics/img/repository_analytics_v13_0.png diff --git a/doc/user/analytics/index.md b/doc/user/analytics/index.md index e58dd771552..48df7bc340a 100644 --- a/doc/user/analytics/index.md +++ b/doc/user/analytics/index.md @@ -39,6 +39,6 @@ The following analytics features are available at the project level: - [Code Review](code_review_analytics.md). **(STARTER)** - [Insights](../group/insights/index.md). **(ULTIMATE)** - [Issues](../group/issues_analytics/index.md). **(PREMIUM)** -- Repository. **(STARTER)** +- [Repository](repository_analytics.md). - [Value Stream](value_stream_analytics.md), enabled with the `cycle_analytics` [feature flag](../../development/feature_flags/development.md#enabling-a-feature-flag-in-development). **(STARTER)** diff --git a/doc/user/analytics/repository_analytics.md b/doc/user/analytics/repository_analytics.md new file mode 100644 index 00000000000..17032990b09 --- /dev/null +++ b/doc/user/analytics/repository_analytics.md @@ -0,0 +1,40 @@ +--- +stage: Manage +group: Analytics +To determine the technical writer assigned to the Stage/Group associated with this page, see: + https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers +--- + +# Repository Analytics + +Get high-level overview of the project's Git repository. + +![Repository Analytics](img/repository_analytics_v13_0.png) + +## Availability + +Repository Analytics is part of [GitLab Community Edition](https://gitlab.com/gitlab-org/gitlab-foss). It's available to anyone who has permission to clone the repository. + +The feature requires: + +- An initialized Git repository. +- At least one commit in the default branch (`master` by default). + +## Overview + +You can find Repository Analytics in the project's sidebar. To access the page, go to **{chart}** **Analytics > Repository**. + +NOTE: **Note:** +Without a Git commit in the default branch, the menu item won't be visible. + +### Charts + +The data in the charts are updated soon after each commit in the default branch. + +Available charts: + +- Programming languages used in the repository +- Commit statistics (last month) +- Commits per day of month +- Commits per weekday +- Commits per day hour (UTC) diff --git a/doc/user/clusters/applications.md b/doc/user/clusters/applications.md index 4ef02895d76..58fd267322f 100644 --- a/doc/user/clusters/applications.md +++ b/doc/user/clusters/applications.md @@ -540,15 +540,12 @@ Save the following to `kibana.yml`: elasticsearch: enabled: false -logstash: +filebeat: enabled: false kibana: enabled: true elasticsearchHosts: http://elastic-stack-elasticsearch-master.gitlab-managed-apps.svc.cluster.local:9200 - -elasticseach-curator: - enabled: false ``` Then install it on your cluster: @@ -561,7 +558,7 @@ helm install --name kibana gitlab/elastic-stack --values kibana.yml To access Kibana, forward the port to your local machine: ```shell -kubectl port-forward svc/kibana 5601:5601 +kubectl port-forward svc/kibana-kibana 5601:5601 ``` Then, you can visit Kibana at `http://localhost:5601`. diff --git a/lib/api/project_templates.rb b/lib/api/project_templates.rb index ba70e98ab9b..cfcc7f5212d 100644 --- a/lib/api/project_templates.rb +++ b/lib/api/project_templates.rb @@ -5,6 +5,10 @@ module API include PaginationParams TEMPLATE_TYPES = %w[dockerfiles gitignores gitlab_ci_ymls licenses].freeze + # The regex is needed to ensure a period (e.g. agpl-3.0) + # isn't confused with a format type. We also need to allow encoded + # values (e.g. C%2B%2B for C++), so allow % and + as well. + TEMPLATE_NAMES_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS.merge(name: /[\w%.+-]+/) before { authenticate_non_get! } @@ -36,10 +40,8 @@ module API optional :project, type: String, desc: 'The project name to use when expanding placeholders in the template. Only affects licenses' optional :fullname, type: String, desc: 'The full name of the copyright holder to use when expanding placeholders in the template. Only affects licenses' end - # The regex is needed to ensure a period (e.g. agpl-3.0) - # isn't confused with a format type. We also need to allow encoded - # values (e.g. C%2B%2B for C++), so allow % and + as well. - get ':id/templates/:type/:name', requirements: { name: /[\w%.+-]+/ } do + + get ':id/templates/:type/:name', requirements: TEMPLATE_NAMES_ENDPOINT_REQUIREMENTS do template = TemplateFinder .build(params[:type], user_project, name: params[:name]) .execute diff --git a/lib/gitlab/kubernetes/helm/parsers/list_v2.rb b/lib/gitlab/kubernetes/helm/parsers/list_v2.rb new file mode 100644 index 00000000000..daa716cdef7 --- /dev/null +++ b/lib/gitlab/kubernetes/helm/parsers/list_v2.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Gitlab + module Kubernetes + module Helm + module Parsers + # Parses Helm v2 list (JSON) output + class ListV2 + ParserError = Class.new(StandardError) + + attr_reader :contents, :json + + def initialize(contents) + @contents = contents + @json = Gitlab::Json.parse(contents) + rescue JSON::ParserError => e + raise ParserError, e.message + end + + def releases + @releases ||= json["Releases"] || [] + end + end + end + end + end +end diff --git a/lib/gitlab/metrics/samplers/ruby_sampler.rb b/lib/gitlab/metrics/samplers/ruby_sampler.rb index 444424c2b74..df59c06911b 100644 --- a/lib/gitlab/metrics/samplers/ruby_sampler.rb +++ b/lib/gitlab/metrics/samplers/ruby_sampler.rb @@ -88,7 +88,7 @@ module Gitlab def set_memory_usage_metrics metrics[:process_resident_memory_bytes].set(labels, System.memory_usage_rss) - if Gitlab::Utils.to_boolean(ENV['enable_memory_uss_pss']) + if Gitlab::Utils.to_boolean(ENV['enable_memory_uss_pss'] || '1') memory_uss_pss = System.memory_usage_uss_pss metrics[:process_unique_memory_bytes].set(labels, memory_uss_pss[:uss]) metrics[:process_proportional_memory_bytes].set(labels, memory_uss_pss[:pss]) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2f69ef5c05c..0e227370741 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1769,6 +1769,12 @@ msgstr "" msgid "AlertManagement|Overview" msgstr "" +msgid "AlertManagement|Reported %{when}" +msgstr "" + +msgid "AlertManagement|Reported %{when} by %{tool}" +msgstr "" + msgid "AlertManagement|Resolved" msgstr "" diff --git a/spec/controllers/concerns/metrics_dashboard_spec.rb b/spec/controllers/concerns/metrics_dashboard_spec.rb index 3a6a037ac9a..f6705ba6acc 100644 --- a/spec/controllers/concerns/metrics_dashboard_spec.rb +++ b/spec/controllers/concerns/metrics_dashboard_spec.rb @@ -134,10 +134,10 @@ describe MetricsDashboard do it 'adds starred dashboard information and sorts the list' do all_dashboards = json_response['all_dashboards'].map { |dashboard| dashboard.slice('display_name', 'starred', 'user_starred_path') } expected_response = [ - { "display_name" => "errors.yml", "starred" => true, 'user_starred_path' => nil }, - { "display_name" => "test.yml", "starred" => true, 'user_starred_path' => nil }, + { "display_name" => "Default", "starred" => false, 'user_starred_path' => nil }, { "display_name" => "anomaly.yml", "starred" => false, 'user_starred_path' => nil }, - { "display_name" => "Default", "starred" => false, 'user_starred_path' => nil } + { "display_name" => "errors.yml", "starred" => true, 'user_starred_path' => nil }, + { "display_name" => "test.yml", "starred" => true, 'user_starred_path' => nil } ] expect(all_dashboards).to eql expected_response diff --git a/spec/frontend/alert_management/components/alert_management_detail_spec.js b/spec/frontend/alert_management/components/alert_management_detail_spec.js index 2758014aaa7..5234be0bf07 100644 --- a/spec/frontend/alert_management/components/alert_management_detail_spec.js +++ b/spec/frontend/alert_management/components/alert_management_detail_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import AlertDetails from '~/alert_management/components/alert_details.vue'; @@ -11,18 +11,20 @@ describe('AlertDetails', () => { const newIssuePath = 'root/alerts/-/issues/new'; function mountComponent({ - data = { alert: {} }, + data, createIssueFromAlertEnabled = false, loading = false, + mountMethod = shallowMount, + stubs = {}, } = {}) { - wrapper = shallowMount(AlertDetails, { + wrapper = mountMethod(AlertDetails, { propsData: { alertId: 'alertId', projectPath: 'projectPath', newIssuePath, }, data() { - return data; + return { alert: { ...mockAlert }, ...data }; }, provide: { glFeatures: { createIssueFromAlertEnabled }, @@ -36,6 +38,7 @@ describe('AlertDetails', () => { }, }, }, + stubs, }); } @@ -149,5 +152,33 @@ describe('AlertDetails', () => { expect(wrapper.find(GlAlert).exists()).toBe(false); }); }); + + describe('header', () => { + const findHeader = () => wrapper.find('[data-testid="alert-header"]'); + const stubs = { TimeAgoTooltip: '<span>now</span>' }; + + describe('individual header fields', () => { + describe.each` + severity | createdAt | monitoringTool | result + ${'MEDIUM'} | ${'2020-04-17T23:18:14.996Z'} | ${null} | ${'Medium • Reported now'} + ${'INFO'} | ${'2020-04-17T23:18:14.996Z'} | ${'Datadog'} | ${'Info • Reported now by Datadog'} + `( + `When severity=$severity, createdAt=$createdAt, monitoringTool=$monitoringTool`, + ({ severity, createdAt, monitoringTool, result }) => { + beforeEach(() => { + mountComponent({ + data: { alert: { ...mockAlert, severity, createdAt, monitoringTool } }, + mountMethod: mount, + stubs, + }); + }); + + it('header text is shown correctly', () => { + expect(findHeader().text()).toBe(result); + }); + }, + ); + }); + }); }); }); diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js index 23da4df188b..597574bdeed 100644 --- a/spec/frontend/ide/components/new_dropdown/modal_spec.js +++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js @@ -91,22 +91,31 @@ describe('new file modal component', () => { expect(vm.entryName).toBe('test-path'); }); - it('updated name', () => { - vm.name = 'index.js'; + it('does not reset entryName to its old value if empty', () => { + vm.entryName = 'hello'; + vm.entryName = ''; - expect(vm.entryName).toBe('index.js'); + expect(vm.entryName).toBe(''); + }); + }); + + describe('open', () => { + it('sets entryName to path provided if modalType is rename', () => { + vm.open('rename', 'test-path'); + + expect(vm.entryName).toBe('test-path'); }); - it('removes leading/trailing spaces when found in the new name', () => { - vm.entryName = ' index.js '; + it("appends '/' to the path if modalType isn't rename", () => { + vm.open('blob', 'test-path'); - expect(vm.entryName).toBe('index.js'); + expect(vm.entryName).toBe('test-path/'); }); - it('does not remove internal spaces in the file name', () => { - vm.entryName = ' In Praise of Idleness.txt '; + it('leaves entryName blank if no path is provided', () => { + vm.open('blob'); - expect(vm.entryName).toBe('In Praise of Idleness.txt'); + expect(vm.entryName).toBe(''); }); }); }); diff --git a/spec/frontend/monitoring/components/variables_section_spec.js b/spec/frontend/monitoring/components/variables_section_spec.js index 7271beea50a..51c0e192c58 100644 --- a/spec/frontend/monitoring/components/variables_section_spec.js +++ b/spec/frontend/monitoring/components/variables_section_spec.js @@ -15,8 +15,8 @@ describe('Metrics dashboard/variables section component', () => { let store; let wrapper; const sampleVariables = { - label1: 'pod', - label2: 'main', + 'var-label1': 'pod', + 'var-label2': 'main', }; const createShallowWrapper = () => { diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js index 2fa88dfa87a..a8443c08b00 100644 --- a/spec/frontend/monitoring/mock_data.js +++ b/spec/frontend/monitoring/mock_data.js @@ -642,7 +642,7 @@ const generateMockTemplatingData = data => { const responseForSimpleTextVariable = { simpleText: { - label: 'simpleText', + label: 'var-simpleText', type: 'text', value: 'Simple text', }, @@ -650,7 +650,7 @@ const responseForSimpleTextVariable = { const responseForAdvTextVariable = { advText: { - label: 'Variable 4', + label: 'var-Variable 4', type: 'text', value: 'default', }, @@ -658,7 +658,7 @@ const responseForAdvTextVariable = { const responseForSimpleCustomVariable = { simpleCustom: { - label: 'simpleCustom', + label: 'var-simpleCustom', options: [ { default: false, @@ -682,7 +682,7 @@ const responseForSimpleCustomVariable = { const responseForAdvancedCustomVariableWithoutOptions = { advCustomWithoutOpts: { - label: 'advCustomWithoutOpts', + label: 'var-advCustomWithoutOpts', options: [], type: 'custom', }, @@ -690,7 +690,7 @@ const responseForAdvancedCustomVariableWithoutOptions = { const responseForAdvancedCustomVariableWithoutLabel = { advCustomWithoutLabel: { - label: 'advCustomWithoutLabel', + label: 'var-advCustomWithoutLabel', options: [ { default: false, @@ -710,7 +710,7 @@ const responseForAdvancedCustomVariableWithoutLabel = { const responseForAdvancedCustomVariable = { ...responseForSimpleCustomVariable, advCustomNormal: { - label: 'Advanced Var', + label: 'var-Advanced Var', options: [ { default: false, diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js index f07ae4c5a1e..e3ee9ffb2bc 100644 --- a/spec/frontend/monitoring/store/getters_spec.js +++ b/spec/frontend/monitoring/store/getters_spec.js @@ -327,7 +327,8 @@ describe('Monitoring store Getters', () => { describe('getCustomVariablesArray', () => { let state; const sampleVariables = { - label1: 'pod', + 'var-label1': 'pod', + 'var-label2': 'env', }; beforeEach(() => { @@ -340,7 +341,7 @@ describe('Monitoring store Getters', () => { mutations[types.SET_PROM_QUERY_VARIABLES](state, sampleVariables); const variablesArray = getters.getCustomVariablesArray(state); - expect(variablesArray).toEqual(['label1', 'pod']); + expect(variablesArray).toEqual(['label1', 'pod', 'label2', 'env']); }); it('transforms the promVariables object to an empty array when no keys are present', () => { diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js index 21597033e0a..52fd776e67e 100644 --- a/spec/frontend/monitoring/utils_spec.js +++ b/spec/frontend/monitoring/utils_spec.js @@ -192,9 +192,10 @@ describe('monitoring/utils', () => { direction: 'left', anchor: 'top', pod: 'POD', + 'var-pod': 'POD', }); - expect(promCustomVariablesFromUrl()).toEqual(expect.objectContaining({ pod: 'POD' })); + expect(promCustomVariablesFromUrl()).toEqual(expect.objectContaining({ 'var-pod': 'POD' })); }); it('returns an empty object when no custom variables are present', () => { diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js index f7b1f041ef2..dd24ecf707d 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js @@ -2,10 +2,7 @@ import Vue from 'vue'; import { mount } from '@vue/test-utils'; import { formatDate } from '~/lib/utils/datetime_utility'; import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; -import { - defaultAssignees, - defaultMilestone, -} from '../../../../javascripts/vue_shared/components/issue/related_issuable_mock_data'; +import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data'; describe('RelatedIssuableItem', () => { let wrapper; diff --git a/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js index b7de40b4831..34ccdf38b00 100644 --- a/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestions_spec.js @@ -37,10 +37,10 @@ const MOCK_DATA = { noteHtml: ` <div class="suggestion"> <div class="line">-oldtest</div> - </div> + </div> <div class="suggestion"> <div class="line">+newtest</div> - </div> + </div> `, isApplied: false, helpPagePath: 'path_to_docs', @@ -59,7 +59,7 @@ describe('Suggestion component', () => { diffTable = vm.generateDiff(0).$mount().$el; - spyOn(vm, 'renderSuggestions'); + jest.spyOn(vm, 'renderSuggestions').mockImplementation(() => {}); vm.renderSuggestions(); Vue.nextTick(done); }); @@ -85,10 +85,6 @@ describe('Suggestion component', () => { expect(diffTable.querySelector('.md-suggestion-diff')).not.toBeNull(); }); - it('generates a diff table that contains contents of `oldLineContent`', () => { - expect(diffTable.innerHTML.includes(vm.fromContent)).toBe(true); - }); - it('generates a diff table that contains contents the suggested lines', () => { MOCK_DATA.suggestions[0].diff_lines.forEach(line => { const text = line.text.substring(1); diff --git a/spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js index 5d995f06abb..29bced394dc 100644 --- a/spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_selector_spec.js @@ -3,7 +3,7 @@ import { head } from 'lodash'; import { GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; -import { trimText } from 'spec/helpers/text_helper'; +import { trimText } from 'helpers/text_helper'; import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue'; @@ -12,7 +12,6 @@ const localVue = createLocalVue(); describe('ProjectSelector component', () => { let wrapper; let vm; - loadJSONFixtures('static/projects.json'); const allProjects = getJSONFixture('static/projects.json'); const searchResults = allProjects.slice(0, 5); let selected = []; @@ -21,9 +20,6 @@ describe('ProjectSelector component', () => { const findSearchInput = () => wrapper.find(GlSearchBoxByType).find('input'); beforeEach(() => { - jasmine.clock().install(); - jasmine.clock().mockDate(); - wrapper = mount(Vue.extend(ProjectSelector), { localVue, propsData: { @@ -41,7 +37,6 @@ describe('ProjectSelector component', () => { }); afterEach(() => { - jasmine.clock().uninstall(); vm.$destroy(); }); @@ -49,42 +44,17 @@ describe('ProjectSelector component', () => { expect(wrapper.findAll('.js-project-list-item').length).toBe(5); }); - it(`triggers a (debounced) search when the search input value changes`, () => { - spyOn(vm, '$emit'); + it(`triggers a search when the search input value changes`, () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); const query = 'my test query!'; const searchInput = findSearchInput(); searchInput.setValue(query); searchInput.trigger('input'); - expect(vm.$emit).not.toHaveBeenCalledWith(); - jasmine.clock().tick(501); - expect(vm.$emit).toHaveBeenCalledWith('searched', query); }); - it(`debounces the search input`, () => { - spyOn(vm, '$emit'); - const searchInput = findSearchInput(); - - const updateSearchQuery = (count = 0) => { - if (count === 10) { - jasmine.clock().tick(101); - - expect(vm.$emit).toHaveBeenCalledTimes(1); - expect(vm.$emit).toHaveBeenCalledWith('searched', `search query #9`); - } else { - searchInput.setValue(`search query #${count}`); - searchInput.trigger('input'); - - jasmine.clock().tick(400); - updateSearchQuery(count + 1); - } - }; - - updateSearchQuery(); - }); - it(`includes a placeholder in the search box`, () => { const searchInput = findSearchInput(); @@ -92,14 +62,14 @@ describe('ProjectSelector component', () => { }); it(`triggers a "bottomReached" event when user has scrolled to the bottom of the list`, () => { - spyOn(vm, '$emit'); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); wrapper.find(GlInfiniteScroll).vm.$emit('bottomReached'); expect(vm.$emit).toHaveBeenCalledWith('bottomReached'); }); it(`triggers a "projectClicked" event when a project is clicked`, () => { - spyOn(vm, '$emit'); + jest.spyOn(vm, '$emit').mockImplementation(() => {}); wrapper.find(ProjectListItem).vm.$emit('click', head(searchResults)); expect(vm.$emit).toHaveBeenCalledWith('projectClicked', head(searchResults)); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_svg_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_svg_spec.js deleted file mode 100644 index ee6c2e2cc46..00000000000 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_svg_spec.js +++ /dev/null @@ -1,27 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import UserAvatarSvg from '~/vue_shared/components/user_avatar/user_avatar_svg.vue'; - -describe('User Avatar Svg Component', () => { - describe('Initialization', () => { - let wrapper; - - beforeEach(() => { - wrapper = shallowMount(UserAvatarSvg, { - propsData: { - size: 99, - svg: - '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M1.707 15.707C1.077z"/></svg>', - }, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('should have <svg> as a child element', () => { - expect(wrapper.element.tagName).toEqual('svg'); - expect(wrapper.html()).toContain('<path'); - }); - }); -}); diff --git a/spec/graphql/mutations/design_management/delete_spec.rb b/spec/graphql/mutations/design_management/delete_spec.rb new file mode 100644 index 00000000000..df587be5089 --- /dev/null +++ b/spec/graphql/mutations/design_management/delete_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Mutations::DesignManagement::Delete do + include DesignManagementTestHelpers + + let(:issue) { create(:issue) } + let(:current_designs) { issue.designs.current } + let(:user) { issue.author } + let(:project) { issue.project } + let(:design_a) { create(:design, :with_file, issue: issue) } + let(:design_b) { create(:design, :with_file, issue: issue) } + let(:design_c) { create(:design, :with_file, issue: issue) } + let(:filenames) { [design_a, design_b, design_c].map(&:filename) } + + let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) } + + before do + # TODO these tests are being temporarily skipped unless run in EE, + # as we are in the process of moving Design Management to FOSS in 13.0 + # in steps. In the current step the services have not yet been moved, + # which are used by this mutation. + # + # See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283. + skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee? + + stub_const('Errors', Gitlab::Graphql::Errors, transfer_nested_constants: true) + end + + def run_mutation + mutation = described_class.new(object: nil, context: { current_user: user }, field: nil) + mutation.resolve(project_path: project.full_path, iid: issue.iid, filenames: filenames) + end + + describe '#resolve' do + let(:expected_response) do + { errors: [], version: DesignManagement::Version.for_issue(issue).ordered.first } + end + + shared_examples "failures" do |error: Gitlab::Graphql::Errors::ResourceNotAvailable| + it "raises #{error.name}" do + expect { run_mutation }.to raise_error(error) + end + end + + shared_examples "resource not available" do + it_behaves_like "failures" + end + + context "when the feature is not available" do + before do + enable_design_management(false) + end + + it_behaves_like "resource not available" + end + + context "when the feature is available" do + before do + enable_design_management(true) + end + + context "when the user is not allowed to delete designs" do + let(:user) { create(:user) } + + it_behaves_like "resource not available" + end + + context 'deleting an already deleted file' do + before do + run_mutation + end + + it 'fails with an argument error' do + expect { run_mutation }.to raise_error(Gitlab::Graphql::Errors::ArgumentError) + end + end + + context "when deleting all the designs" do + let(:response) { run_mutation } + + it "returns a new version, and no errors" do + expect(response).to include(expected_response) + end + + describe 'the current designs' do + before do + run_mutation + end + + it 'is empty' do + expect(current_designs).to be_empty + end + end + + it 'runs no more than 28 queries' do + filenames.each(&:present?) # ignore setup + # Queries: as of 2019-08-28 + # ------------- + # 01. routing query + # 02. find project by id + # 03. project.project_features + # 04. find namespace by id and type + # 05,06. project.authorizations for user (same query twice) + # 07. find issue by iid + # 08. find project by id + # 09. find namespace by id + # 10. find group namespace by id + # 11. project.authorizations for user (same query as 5) + # 12. project.project_features (same query as 3) + # 13. project.authorizations for user (same query as 5) + # 14. current designs by filename and issue + # 15, 16 project.authorizations for user (same query as 5) + # 17. find route by id and source_type + # ------------- our queries are below: + # 18. start transaction 1 + # 19. start transaction 2 + # 20. find version by sha and issue + # 21. exists version with sha and issue? + # 22. leave transaction 2 + # 23. create version with sha and issue + # 24. create design-version links + # 25. validate version.actions.present? + # 26. validate version.issue.present? + # 27. validate version.sha is unique + # 28. leave transaction 1 + # + expect { run_mutation }.not_to exceed_query_limit(28) + end + end + + context "when deleting a design" do + let(:filenames) { [design_a.filename] } + let(:response) { run_mutation } + + it "returns the expected response" do + expect(response).to include(expected_response) + end + + describe 'the current designs' do + before do + run_mutation + end + + it 'does contain designs b and c' do + expect(current_designs).to contain_exactly(design_b, design_c) + end + end + end + end + end +end diff --git a/spec/graphql/mutations/design_management/upload_spec.rb b/spec/graphql/mutations/design_management/upload_spec.rb new file mode 100644 index 00000000000..10c2164caec --- /dev/null +++ b/spec/graphql/mutations/design_management/upload_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true +require 'spec_helper' + +describe Mutations::DesignManagement::Upload do + include DesignManagementTestHelpers + include ConcurrentHelpers + + let(:issue) { create(:issue) } + let(:user) { issue.author } + let(:project) { issue.project } + + subject(:mutation) do + described_class.new(object: nil, context: { current_user: user }, field: nil) + end + + def run_mutation(files_to_upload = files, project_path = project.full_path, iid = issue.iid) + mutation = described_class.new(object: nil, context: { current_user: user }, field: nil) + mutation.resolve(project_path: project_path, iid: iid, files: files_to_upload) + end + + describe "#resolve" do + let(:files) { [fixture_file_upload('spec/fixtures/dk.png')] } + + subject(:resolve) do + mutation.resolve(project_path: project.full_path, iid: issue.iid, files: files) + end + + shared_examples "resource not available" do + it "raises an error" do + expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context "when the feature is not available" do + it_behaves_like "resource not available" + end + + context "when the feature is available" do + before do + # TODO these tests are being temporarily skipped unless run in EE, + # as we are in the process of moving Design Management to FOSS in 13.0 + # in steps. In the current step the services have not yet been moved, + # which are used by this mutation. + # + # See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283. + skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee? + + enable_design_management + end + + describe 'contention in the design repo' do + before do + issue.design_collection.repository.create_if_not_exists + end + + let(:files) do + ['dk.png', 'rails_sample.jpg', 'banana_sample.gif'] + .cycle + .take(Concurrent.processor_count * 2) + .map { |f| RenameableUpload.unique_file(f) } + end + + def creates_designs + prior_count = DesignManagement::Design.count + + expect { yield }.not_to raise_error + + expect(DesignManagement::Design.count).to eq(prior_count + files.size) + end + + describe 'running requests in parallel' do + it 'does not cause errors' do + creates_designs do + run_parallel(files.map { |f| -> { run_mutation([f]) } }) + end + end + end + + describe 'running requests in parallel on different issues' do + it 'does not cause errors' do + creates_designs do + issues = create_list(:issue, files.size, author: user) + issues.each { |i| i.project.add_developer(user) } + blocks = files.zip(issues).map do |(f, i)| + -> { run_mutation([f], i.project.full_path, i.iid) } + end + + run_parallel(blocks) + end + end + end + + describe 'running requests in serial' do + it 'does not cause errors' do + creates_designs do + files.each do |f| + run_mutation([f]) + end + end + end + end + end + + context "when the user is not allowed to upload designs" do + let(:user) { create(:user) } + + it_behaves_like "resource not available" + end + + context "a valid design" do + it "returns the updated designs" do + expect(resolve[:errors]).to eq [] + expect(resolve[:designs].map(&:filename)).to contain_exactly("dk.png") + end + end + + context "context when passing an invalid project" do + let(:project) { build(:project) } + + it_behaves_like "resource not available" + end + + context "context when passing an invalid issue" do + let(:issue) { build(:issue) } + + it_behaves_like "resource not available" + end + + context "when creating designs causes errors" do + before do + fake_service = double(::DesignManagement::SaveDesignsService) + + allow(fake_service).to receive(:execute).and_return(status: :error, message: "Something failed") + allow(::DesignManagement::SaveDesignsService).to receive(:new).and_return(fake_service) + end + + it "wraps the errors" do + expect(resolve[:errors]).to eq(["Something failed"]) + expect(resolve[:designs]).to eq([]) + end + end + end + end +end diff --git a/spec/graphql/resolvers/design_management/design_at_version_resolver_spec.rb b/spec/graphql/resolvers/design_management/design_at_version_resolver_spec.rb new file mode 100644 index 00000000000..a5054ae3ebf --- /dev/null +++ b/spec/graphql/resolvers/design_management/design_at_version_resolver_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::DesignManagement::DesignAtVersionResolver do + include GraphqlHelpers + include DesignManagementTestHelpers + + let_it_be(:issue) { create(:issue) } + let_it_be(:project) { issue.project } + let_it_be(:user) { create(:user) } + let_it_be(:design_a) { create(:design, issue: issue) } + let_it_be(:version_a) { create(:design_version, issue: issue, created_designs: [design_a]) } + + let(:current_user) { user } + let(:object) { issue.design_collection } + let(:global_id) { GitlabSchema.id_from_object(design_at_version).to_s } + + let(:design_at_version) { ::DesignManagement::DesignAtVersion.new(design: design_a, version: version_a) } + + let(:resource_not_available) { ::Gitlab::Graphql::Errors::ResourceNotAvailable } + + before do + enable_design_management + project.add_developer(user) + end + + describe '#resolve' do + context 'when the user cannot see designs' do + let(:current_user) { create(:user) } + + it 'raises ResourceNotAvailable' do + expect { resolve_design }.to raise_error(resource_not_available) + end + end + + it 'returns the specified design' do + expect(resolve_design).to eq(design_at_version) + end + + context 'the ID belongs to a design on another issue' do + let(:other_dav) do + create(:design_at_version, issue: create(:issue, project: project)) + end + + let(:global_id) { global_id_of(other_dav) } + + it 'raises ResourceNotAvailable' do + expect { resolve_design }.to raise_error(resource_not_available) + end + + context 'the current object does not constrain the issue' do + let(:object) { nil } + + it 'returns the object' do + expect(resolve_design).to eq(other_dav) + end + end + end + end + + private + + def resolve_design + args = { id: global_id } + ctx = { current_user: current_user } + eager_resolve(described_class, obj: object, args: args, ctx: ctx) + end +end diff --git a/spec/graphql/resolvers/design_management/design_resolver_spec.rb b/spec/graphql/resolvers/design_management/design_resolver_spec.rb new file mode 100644 index 00000000000..857acc3d371 --- /dev/null +++ b/spec/graphql/resolvers/design_management/design_resolver_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::DesignManagement::DesignResolver do + include GraphqlHelpers + include DesignManagementTestHelpers + + before do + enable_design_management + end + + describe '#resolve' do + let_it_be(:issue) { create(:issue) } + let_it_be(:project) { issue.project } + let_it_be(:first_version) { create(:design_version) } + let_it_be(:first_design) { create(:design, issue: issue, versions: [first_version]) } + let_it_be(:current_user) { create(:user) } + let_it_be(:design_on_other_issue) do + create(:design, issue: create(:issue, project: project), versions: [create(:design_version)]) + end + + let(:args) { { id: GitlabSchema.id_from_object(first_design).to_s } } + let(:gql_context) { { current_user: current_user } } + + before do + project.add_developer(current_user) + end + + context 'when the user cannot see designs' do + let(:gql_context) { { current_user: create(:user) } } + + it 'returns nothing' do + expect(resolve_design).to be_nil + end + end + + context 'when no argument has been passed' do + let(:args) { {} } + + it 'raises an error' do + expect { resolve_design }.to raise_error(::Gitlab::Graphql::Errors::ArgumentError, /must/) + end + end + + context 'when both arguments have been passed' do + let(:args) { { filename: first_design.filename, id: GitlabSchema.id_from_object(first_design).to_s } } + + it 'raises an error' do + expect { resolve_design }.to raise_error(::Gitlab::Graphql::Errors::ArgumentError, /may/) + end + end + + context 'by ID' do + it 'returns the specified design' do + expect(resolve_design).to eq(first_design) + end + + context 'the ID belongs to a design on another issue' do + let(:args) { { id: GitlabSchema.id_from_object(design_on_other_issue).to_s } } + + it 'returns nothing' do + expect(resolve_design).to be_nil + end + end + end + + context 'by filename' do + let(:args) { { filename: first_design.filename } } + + it 'returns the specified design' do + expect(resolve_design).to eq(first_design) + end + + context 'the filename belongs to a design on another issue' do + let(:args) { { filename: design_on_other_issue.filename } } + + it 'returns nothing' do + expect(resolve_design).to be_nil + end + end + end + end + + def resolve_design + resolve(described_class, obj: issue.design_collection, args: args, ctx: gql_context) + end +end diff --git a/spec/graphql/resolvers/design_management/designs_resolver_spec.rb b/spec/graphql/resolvers/design_management/designs_resolver_spec.rb new file mode 100644 index 00000000000..28fc9e2151d --- /dev/null +++ b/spec/graphql/resolvers/design_management/designs_resolver_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::DesignManagement::DesignsResolver do + include GraphqlHelpers + include DesignManagementTestHelpers + + before do + enable_design_management + end + + describe '#resolve' do + let_it_be(:issue) { create(:issue) } + let_it_be(:project) { issue.project } + let_it_be(:first_version) { create(:design_version) } + let_it_be(:first_design) { create(:design, issue: issue, versions: [first_version]) } + let_it_be(:current_user) { create(:user) } + let(:gql_context) { { current_user: current_user } } + let(:args) { {} } + + before do + project.add_developer(current_user) + end + + context 'when the user cannot see designs' do + let(:gql_context) { { current_user: create(:user) } } + + it 'returns nothing' do + expect(resolve_designs).to be_empty + end + end + + context 'for a design collection' do + context 'which contains just a single design' do + it 'returns just that design' do + expect(resolve_designs).to contain_exactly(first_design) + end + end + + context 'which contains another design' do + it 'returns all designs' do + second_version = create(:design_version) + second_design = create(:design, issue: issue, versions: [second_version]) + + expect(resolve_designs).to contain_exactly(first_design, second_design) + end + end + end + + describe 'filtering' do + describe 'by filename' do + let(:second_version) { create(:design_version) } + let(:second_design) { create(:design, issue: issue, versions: [second_version]) } + let(:args) { { filenames: [second_design.filename] } } + + it 'resolves to just the relevant design, ignoring designs with the same filename on different issues' do + create(:design, issue: create(:issue, project: project), filename: second_design.filename) + + expect(resolve_designs).to contain_exactly(second_design) + end + end + + describe 'by id' do + let(:second_version) { create(:design_version) } + let(:second_design) { create(:design, issue: issue, versions: [second_version]) } + + context 'the ID is on the current issue' do + let(:args) { { ids: [GitlabSchema.id_from_object(second_design).to_s] } } + + it 'resolves to just the relevant design' do + expect(resolve_designs).to contain_exactly(second_design) + end + end + + context 'the ID is on a different issue' do + let(:third_version) { create(:design_version) } + let(:third_design) { create(:design, issue: create(:issue, project: project), versions: [third_version]) } + + let(:args) { { ids: [GitlabSchema.id_from_object(third_design).to_s] } } + + it 'ignores it' do + expect(resolve_designs).to be_empty + end + end + end + end + end + + def resolve_designs + resolve(described_class, obj: issue.design_collection, args: args, ctx: gql_context) + end +end diff --git a/spec/graphql/resolvers/design_management/version/design_at_version_resolver_spec.rb b/spec/graphql/resolvers/design_management/version/design_at_version_resolver_spec.rb new file mode 100644 index 00000000000..cc9c0436885 --- /dev/null +++ b/spec/graphql/resolvers/design_management/version/design_at_version_resolver_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::DesignManagement::Version::DesignAtVersionResolver do + include GraphqlHelpers + + include_context 'four designs in three versions' + + let(:current_user) { authorized_user } + let(:gql_context) { { current_user: current_user } } + + let(:version) { third_version } + let(:design) { design_a } + + let(:all_singular_args) do + { + design_at_version_id: global_id_of(dav(design)), + design_id: global_id_of(design), + filename: design.filename + } + end + + shared_examples 'a bad argument' do + let(:err_class) { ::Gitlab::Graphql::Errors::ArgumentError } + + it 'raises an appropriate error' do + expect { resolve_objects }.to raise_error(err_class) + end + end + + describe '#resolve' do + describe 'passing combinations of arguments' do + context 'passing no arguments' do + let(:args) { {} } + + it_behaves_like 'a bad argument' + end + + context 'passing all arguments' do + let(:args) { all_singular_args } + + it_behaves_like 'a bad argument' + end + + context 'passing any two arguments' do + let(:args) { all_singular_args.slice(*all_singular_args.keys.sample(2)) } + + it_behaves_like 'a bad argument' + end + end + + %i[design_at_version_id design_id filename].each do |arg| + describe "passing #{arg}" do + let(:args) { all_singular_args.slice(arg) } + + it 'finds the design' do + expect(resolve_objects).to eq(dav(design)) + end + + context 'when the user cannot see designs' do + let(:current_user) { create(:user) } + + it 'returns nothing' do + expect(resolve_objects).to be_nil + end + end + end + end + + describe 'attempting to retrieve an object not visible at this version' do + let(:design) { design_d } + + %i[design_at_version_id design_id filename].each do |arg| + describe "passing #{arg}" do + let(:args) { all_singular_args.slice(arg) } + + it 'does not find the design' do + expect(resolve_objects).to be_nil + end + end + end + end + end + + def resolve_objects + resolve(described_class, obj: version, args: args, ctx: gql_context) + end + + def dav(design) + build(:design_at_version, design: design, version: version) + end +end diff --git a/spec/graphql/resolvers/design_management/version/designs_at_version_resolver_spec.rb b/spec/graphql/resolvers/design_management/version/designs_at_version_resolver_spec.rb new file mode 100644 index 00000000000..123b26862d0 --- /dev/null +++ b/spec/graphql/resolvers/design_management/version/designs_at_version_resolver_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::DesignManagement::Version::DesignsAtVersionResolver do + include GraphqlHelpers + + include_context 'four designs in three versions' + + let_it_be(:current_user) { authorized_user } + let(:gql_context) { { current_user: current_user } } + + let(:version) { third_version } + + describe '.single' do + let(:single) { ::Resolvers::DesignManagement::Version::DesignAtVersionResolver } + + it 'returns the single context resolver' do + expect(described_class.single).to eq(single) + end + end + + describe '#resolve' do + let(:args) { {} } + + context 'when the user cannot see designs' do + let(:current_user) { create(:user) } + + it 'returns nothing' do + expect(resolve_objects).to be_empty + end + end + + context 'for the current version' do + it 'returns all designs visible at that version' do + expect(resolve_objects).to contain_exactly(dav(design_a), dav(design_b), dav(design_c)) + end + end + + context 'for a previous version with more objects' do + let(:version) { second_version } + + it 'returns objects that were later deleted' do + expect(resolve_objects).to contain_exactly(dav(design_a), dav(design_b), dav(design_c), dav(design_d)) + end + end + + context 'for a previous version with fewer objects' do + let(:version) { first_version } + + it 'does not return objects that were later created' do + expect(resolve_objects).to contain_exactly(dav(design_a)) + end + end + + describe 'filtering' do + describe 'by filename' do + let(:red_herring) { create(:design, issue: create(:issue, project: project)) } + let(:args) { { filenames: [design_b.filename, red_herring.filename] } } + + it 'resolves to just the relevant design' do + create(:design, issue: create(:issue, project: project), filename: design_b.filename) + + expect(resolve_objects).to contain_exactly(dav(design_b)) + end + end + + describe 'by id' do + let(:red_herring) { create(:design, issue: create(:issue, project: project)) } + let(:args) { { ids: [design_a, red_herring].map { |x| global_id_of(x) } } } + + it 'resolves to just the relevant design, ignoring objects on other issues' do + expect(resolve_objects).to contain_exactly(dav(design_a)) + end + end + end + end + + def resolve_objects + resolve(described_class, obj: version, args: args, ctx: gql_context) + end + + def dav(design) + build(:design_at_version, design: design, version: version) + end +end diff --git a/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb b/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb new file mode 100644 index 00000000000..ef50598d241 --- /dev/null +++ b/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::DesignManagement::VersionInCollectionResolver do + include GraphqlHelpers + include DesignManagementTestHelpers + + let(:resolver) { described_class } + + describe '#resolve' do + let_it_be(:issue) { create(:issue) } + let_it_be(:current_user) { create(:user) } + let_it_be(:first_version) { create(:design_version, issue: issue) } + + let(:project) { issue.project } + let(:params) { {} } + + before do + enable_design_management + project.add_developer(current_user) + end + + let(:appropriate_error) { ::Gitlab::Graphql::Errors::ArgumentError } + + subject(:result) { resolve_version(issue.design_collection) } + + context 'Neither id nor sha is passed as parameters' do + it 'raises an appropriate error' do + expect { result }.to raise_error(appropriate_error) + end + end + + context 'we pass an id' do + let(:params) { { id: global_id_of(first_version) } } + + it { is_expected.to eq(first_version) } + end + + context 'we pass a sha' do + let(:params) { { sha: first_version.sha } } + + it { is_expected.to eq(first_version) } + end + + context 'we pass an inconsistent mixture of sha and version id' do + let(:params) { { sha: first_version.sha, id: global_id_of(create(:design_version)) } } + + it { is_expected.to be_nil } + end + + context 'we pass the id of something that is not a design_version' do + let(:params) { { id: global_id_of(project) } } + + it 'raises an appropriate error' do + expect { result }.to raise_error(appropriate_error) + end + end + end + + def resolve_version(obj, context = { current_user: current_user }) + resolve(resolver, obj: obj, args: params, ctx: context) + end +end diff --git a/spec/graphql/resolvers/design_management/version_resolver_spec.rb b/spec/graphql/resolvers/design_management/version_resolver_spec.rb new file mode 100644 index 00000000000..e7c09351204 --- /dev/null +++ b/spec/graphql/resolvers/design_management/version_resolver_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::DesignManagement::VersionResolver do + include GraphqlHelpers + include DesignManagementTestHelpers + + let_it_be(:issue) { create(:issue) } + let_it_be(:current_user) { create(:user) } + let_it_be(:version) { create(:design_version, issue: issue) } + let_it_be(:developer) { create(:user) } + + let(:project) { issue.project } + let(:params) { { id: global_id_of(version) } } + + before do + enable_design_management + project.add_developer(developer) + end + + context 'the current user is not authorized' do + let(:current_user) { create(:user) } + + it 'raises an error on resolution' do + expect { resolve_version }.to raise_error(::Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'the current user is authorized' do + let(:current_user) { developer } + + context 'the id parameter is provided' do + it 'returns the specified version' do + expect(resolve_version).to eq(version) + end + end + end + + def resolve_version + resolve(described_class, obj: nil, args: params, ctx: { current_user: current_user }) + end +end diff --git a/spec/graphql/resolvers/design_management/versions_resolver_spec.rb b/spec/graphql/resolvers/design_management/versions_resolver_spec.rb new file mode 100644 index 00000000000..d5bab025e45 --- /dev/null +++ b/spec/graphql/resolvers/design_management/versions_resolver_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Resolvers::DesignManagement::VersionsResolver do + include GraphqlHelpers + include DesignManagementTestHelpers + + describe '#resolve' do + let(:resolver) { described_class } + let_it_be(:issue) { create(:issue) } + let_it_be(:authorized_user) { create(:user) } + let_it_be(:first_version) { create(:design_version, issue: issue) } + let_it_be(:other_version) { create(:design_version, issue: issue) } + let_it_be(:first_design) { create(:design, issue: issue, versions: [first_version, other_version]) } + let_it_be(:other_design) { create(:design, :with_versions, issue: issue) } + + let(:project) { issue.project } + let(:params) { {} } + let(:current_user) { authorized_user } + let(:parent_args) { { irrelevant: 1.2 } } + let(:parent) { double('Parent', parent: nil, irep_node: double(arguments: parent_args)) } + + before do + enable_design_management + project.add_developer(authorized_user) + end + + shared_examples 'a source of versions' do + subject(:result) { resolve_versions(object) } + + let_it_be(:all_versions) { object.versions.ordered } + + context 'when the user is not authorized' do + let(:current_user) { create(:user) } + + it { is_expected.to be_empty } + end + + context 'without constraints' do + it 'returns the ordered versions' do + expect(result).to eq(all_versions) + end + end + + context 'when constrained' do + let_it_be(:matching) { all_versions.earlier_or_equal_to(first_version) } + + shared_examples 'a query for all_versions up to the first_version' do + it { is_expected.to eq(matching) } + end + + context 'by earlier_or_equal_to_id' do + let(:params) { { id: global_id_of(first_version) } } + + it_behaves_like 'a query for all_versions up to the first_version' + end + + context 'by earlier_or_equal_to_sha' do + let(:params) { { sha: first_version.sha } } + + it_behaves_like 'a query for all_versions up to the first_version' + end + + context 'by earlier_or_equal_to_sha AND earlier_or_equal_to_id' do + context 'and they match' do + # This usage is rather dumb, but so long as they match, this will + # return successfully + let(:params) do + { + sha: first_version.sha, + id: global_id_of(first_version) + } + end + + it_behaves_like 'a query for all_versions up to the first_version' + end + + context 'and they do not match' do + let(:params) do + { + sha: first_version.sha, + id: global_id_of(other_version) + } + end + + it 'raises a suitable error' do + expect { result }.to raise_error(GraphQL::ExecutionError) + end + end + end + + context 'by at_version in parent' do + let(:parent_args) { { atVersion: global_id_of(first_version) } } + + it_behaves_like 'a query for all_versions up to the first_version' + end + end + end + + describe 'a design collection' do + let_it_be(:object) { DesignManagement::DesignCollection.new(issue) } + + it_behaves_like 'a source of versions' + end + + describe 'a design' do + let_it_be(:object) { first_design } + + it_behaves_like 'a source of versions' + end + + def resolve_versions(obj, context = { current_user: current_user }) + eager_resolve(resolver, obj: obj, args: params.merge(parent: parent), ctx: context) + end + end +end diff --git a/spec/graphql/types/design_management/design_at_version_type_spec.rb b/spec/graphql/types/design_management/design_at_version_type_spec.rb new file mode 100644 index 00000000000..1453d73d59c --- /dev/null +++ b/spec/graphql/types/design_management/design_at_version_type_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['DesignAtVersion'] do + it_behaves_like 'a GraphQL type with design fields' do + let(:extra_design_fields) { %i[version design] } + let_it_be(:design) { create(:design, :with_versions) } + let(:object_id) do + version = design.versions.first + GitlabSchema.id_from_object(create(:design_at_version, design: design, version: version)) + end + let_it_be(:object_id_b) { GitlabSchema.id_from_object(create(:design_at_version)) } + let(:object_type) { ::Types::DesignManagement::DesignAtVersionType } + end +end diff --git a/spec/graphql/types/design_management/design_collection_type_spec.rb b/spec/graphql/types/design_management/design_collection_type_spec.rb new file mode 100644 index 00000000000..65150f0971d --- /dev/null +++ b/spec/graphql/types/design_management/design_collection_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['DesignCollection'] do + it { expect(described_class).to require_graphql_authorizations(:read_design) } + + it 'has the expected fields' do + expected_fields = %i[project issue designs versions version designAtVersion design] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/design_management/design_type_spec.rb b/spec/graphql/types/design_management/design_type_spec.rb new file mode 100644 index 00000000000..75b4cd66d5e --- /dev/null +++ b/spec/graphql/types/design_management/design_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['Design'] do + it_behaves_like 'a GraphQL type with design fields' do + let(:extra_design_fields) { %i[notes discussions versions] } + let_it_be(:design) { create(:design, :with_versions) } + let(:object_id) { GitlabSchema.id_from_object(design) } + let_it_be(:object_id_b) { GitlabSchema.id_from_object(create(:design, :with_versions)) } + let(:object_type) { ::Types::DesignManagement::DesignType } + end +end diff --git a/spec/graphql/types/design_management/design_version_event_enum_spec.rb b/spec/graphql/types/design_management/design_version_event_enum_spec.rb new file mode 100644 index 00000000000..a65f1bb5990 --- /dev/null +++ b/spec/graphql/types/design_management/design_version_event_enum_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['DesignVersionEvent'] do + it { expect(described_class.graphql_name).to eq('DesignVersionEvent') } + + it 'exposes the correct event states' do + expect(described_class.values.keys).to include(*%w[CREATION MODIFICATION DELETION NONE]) + end +end diff --git a/spec/graphql/types/design_management/version_type_spec.rb b/spec/graphql/types/design_management/version_type_spec.rb new file mode 100644 index 00000000000..3317c4c6571 --- /dev/null +++ b/spec/graphql/types/design_management/version_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['DesignVersion'] do + it { expect(described_class).to require_graphql_authorizations(:read_design) } + + it 'has the expected fields' do + expected_fields = %i[id sha designs design_at_version designs_at_version] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/design_management_type_spec.rb b/spec/graphql/types/design_management_type_spec.rb new file mode 100644 index 00000000000..a6204f20f23 --- /dev/null +++ b/spec/graphql/types/design_management_type_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe GitlabSchema.types['DesignManagement'] do + it { expect(described_class).to have_graphql_fields(:version, :design_at_version) } +end diff --git a/spec/graphql/types/issue_type_spec.rb b/spec/graphql/types/issue_type_spec.rb index d51457b29b4..a8f7edcfe8e 100644 --- a/spec/graphql/types/issue_type_spec.rb +++ b/spec/graphql/types/issue_type_spec.rb @@ -14,7 +14,8 @@ describe GitlabSchema.types['Issue'] do it 'has specific fields' do fields = %i[iid title description state reference author assignees participants labels milestone due_date confidential discussion_locked upvotes downvotes user_notes_count web_path web_url relative_position - subscribed time_estimate total_time_spent closed_at created_at updated_at task_completion_status] + subscribed time_estimate total_time_spent closed_at created_at updated_at task_completion_status + designs design_collection] fields.each do |field_name| expect(described_class).to have_graphql_field(field_name) diff --git a/spec/graphql/types/notes/noteable_type_spec.rb b/spec/graphql/types/notes/noteable_type_spec.rb index 3176134fa14..4a81f45bd4e 100644 --- a/spec/graphql/types/notes/noteable_type_spec.rb +++ b/spec/graphql/types/notes/noteable_type_spec.rb @@ -8,6 +8,7 @@ describe Types::Notes::NoteableType do it 'knows the correct type for objects' do expect(described_class.resolve_type(build(:issue), {})).to eq(Types::IssueType) expect(described_class.resolve_type(build(:merge_request), {})).to eq(Types::MergeRequestType) + expect(described_class.resolve_type(build(:design), {})).to eq(Types::DesignManagement::DesignType) end end end diff --git a/spec/graphql/types/permission_types/issue_spec.rb b/spec/graphql/types/permission_types/issue_spec.rb index a94bc6b780e..a7a3dd00f11 100644 --- a/spec/graphql/types/permission_types/issue_spec.rb +++ b/spec/graphql/types/permission_types/issue_spec.rb @@ -5,8 +5,9 @@ require 'spec_helper' describe Types::PermissionTypes::Issue do it do expected_permissions = [ - :read_issue, :admin_issue, :update_issue, - :create_note, :reopen_issue + :read_issue, :admin_issue, :update_issue, :reopen_issue, + :read_design, :create_design, :destroy_design, + :create_note ] expected_permissions.each do |permission| diff --git a/spec/graphql/types/permission_types/project_spec.rb b/spec/graphql/types/permission_types/project_spec.rb index 56c4c2de4df..2789464d29c 100644 --- a/spec/graphql/types/permission_types/project_spec.rb +++ b/spec/graphql/types/permission_types/project_spec.rb @@ -13,7 +13,7 @@ describe Types::PermissionTypes::Project do :create_merge_request_from, :create_wiki, :push_code, :create_deployment, :push_to_delete_protected_branch, :admin_wiki, :admin_project, :update_pages, :admin_remote_mirror, :create_label, :update_wiki, :destroy_wiki, :create_pages, :destroy_pages, :read_pages_content, - :read_merge_request + :read_merge_request, :read_design, :create_design, :destroy_design ] expected_permissions.each do |permission| diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb index ab210f2e918..1f269a80d00 100644 --- a/spec/graphql/types/query_type_spec.rb +++ b/spec/graphql/types/query_type_spec.rb @@ -8,7 +8,7 @@ describe GitlabSchema.types['Query'] do end it 'has the expected fields' do - expected_fields = %i[project namespace group echo metadata current_user snippets] + expected_fields = %i[project namespace group echo metadata current_user snippets design_management] expect(described_class).to have_graphql_fields(*expected_fields).at_least end diff --git a/spec/lib/gitlab/git_access_design_spec.rb b/spec/lib/gitlab/git_access_design_spec.rb index b09afc67c90..d816608f7e5 100644 --- a/spec/lib/gitlab/git_access_design_spec.rb +++ b/spec/lib/gitlab/git_access_design_spec.rb @@ -21,16 +21,7 @@ describe Gitlab::GitAccessDesign do end context 'when the user is allowed to manage designs' do - # TODO This test is being temporarily skipped unless run in EE, - # as we are in the process of moving Design Management to FOSS in 13.0 - # in steps. In the current step the policies have not yet been moved - # which means that although the `GitAccessDesign` class has moved, the - # user will always be denied access in FOSS. - # - # See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283. it do - skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee? - is_expected.to be_a(::Gitlab::GitAccessResult::Success) end end diff --git a/spec/lib/gitlab/kubernetes/helm/parsers/list_v2_spec.rb b/spec/lib/gitlab/kubernetes/helm/parsers/list_v2_spec.rb new file mode 100644 index 00000000000..b4b1d8bec51 --- /dev/null +++ b/spec/lib/gitlab/kubernetes/helm/parsers/list_v2_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +describe Gitlab::Kubernetes::Helm::Parsers::ListV2 do + let(:valid_file_contents) do + <<~EOF + { + "Next": "", + "Releases": [ + { + "Name": "certmanager", + "Revision": 2, + "Updated": "Sun Mar 29 06:55:42 2020", + "Status": "DEPLOYED", + "Chart": "cert-manager-v0.10.1", + "AppVersion": "v0.10.1", + "Namespace": "gitlab-managed-apps" + }, + { + "Name": "certmanager-crds", + "Revision": 2, + "Updated": "Sun Mar 29 06:55:32 2020", + "Status": "DEPLOYED", + "Chart": "cert-manager-crds-v0.2.0", + "AppVersion": "release-0.10", + "Namespace": "gitlab-managed-apps" + }, + { + "Name": "certmanager-issuer", + "Revision": 1, + "Updated": "Tue Feb 18 10:04:04 2020", + "Status": "FAILED", + "Chart": "cert-manager-issuer-v0.1.0", + "AppVersion": "", + "Namespace": "gitlab-managed-apps" + }, + { + "Name": "runner", + "Revision": 2, + "Updated": "Sun Mar 29 07:01:01 2020", + "Status": "DEPLOYED", + "Chart": "gitlab-runner-0.14.0", + "AppVersion": "12.8.0", + "Namespace": "gitlab-managed-apps" + } + ] + } + EOF + end + + describe '#initialize' do + it 'initializes without error' do + expect do + described_class.new(valid_file_contents) + end.not_to raise_error + end + + it 'raises an error on invalid JSON' do + expect do + described_class.new('') + end.to raise_error(described_class::ParserError, 'A JSON text must at least contain two octets!') + end + end + + describe '#releases' do + subject(:list_v2) { described_class.new(valid_file_contents) } + + it 'returns list of releases' do + expect(list_v2.releases).to match([ + a_hash_including('Name' => 'certmanager', 'Status' => 'DEPLOYED'), + a_hash_including('Name' => 'certmanager-crds', 'Status' => 'DEPLOYED'), + a_hash_including('Name' => 'certmanager-issuer', 'Status' => 'FAILED'), + a_hash_including('Name' => 'runner', 'Status' => 'DEPLOYED') + ]) + end + + context 'empty Releases' do + let(:valid_file_contents) { '{}' } + + it 'returns an empty array' do + expect(list_v2.releases).to eq([]) + end + end + end +end diff --git a/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb index a2626f8d680..ead650a27f0 100644 --- a/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb +++ b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb @@ -8,7 +8,6 @@ describe Gitlab::Metrics::Samplers::RubySampler do before do allow(Gitlab::Metrics::NullMetric).to receive(:instance).and_return(null_metric) - stub_env('enable_memory_uss_pss', "1") end describe '#initialize' do @@ -37,6 +36,21 @@ describe Gitlab::Metrics::Samplers::RubySampler do sampler.sample end + context 'when USS+PSS sampling is disabled via environment' do + before do + stub_env('enable_memory_uss_pss', "0") + end + + it 'does not sample USS or PSS' do + expect(Gitlab::Metrics::System).not_to receive(:memory_usage_uss_pss) + + expect(sampler.metrics[:process_unique_memory_bytes]).not_to receive(:set) + expect(sampler.metrics[:process_proportional_memory_bytes]).not_to receive(:set) + + sampler.sample + end + end + it 'adds a metric containing the amount of open file descriptors' do expect(Gitlab::Metrics::System).to receive(:file_descriptor_count) .and_return(4) diff --git a/spec/requests/api/graphql/current_user/todos_query_spec.rb b/spec/requests/api/graphql/current_user/todos_query_spec.rb index 82deba0d92c..321e1062a96 100644 --- a/spec/requests/api/graphql/current_user/todos_query_spec.rb +++ b/spec/requests/api/graphql/current_user/todos_query_spec.rb @@ -9,6 +9,7 @@ describe 'Query current user todos' do let_it_be(:commit_todo) { create(:on_commit_todo, user: current_user, project: create(:project, :repository)) } let_it_be(:issue_todo) { create(:todo, user: current_user, target: create(:issue)) } let_it_be(:merge_request_todo) { create(:todo, user: current_user, target: create(:merge_request)) } + let_it_be(:design_todo) { create(:todo, user: current_user, target: create(:design)) } let(:fields) do <<~QUERY @@ -34,7 +35,8 @@ describe 'Query current user todos' do is_expected.to include( a_hash_including('id' => commit_todo.to_global_id.to_s), a_hash_including('id' => issue_todo.to_global_id.to_s), - a_hash_including('id' => merge_request_todo.to_global_id.to_s) + a_hash_including('id' => merge_request_todo.to_global_id.to_s), + a_hash_including('id' => design_todo.to_global_id.to_s) ) end @@ -42,7 +44,8 @@ describe 'Query current user todos' do is_expected.to include( a_hash_including('targetType' => 'COMMIT'), a_hash_including('targetType' => 'ISSUE'), - a_hash_including('targetType' => 'MERGEREQUEST') + a_hash_including('targetType' => 'MERGEREQUEST'), + a_hash_including('targetType' => 'DESIGN') ) end end diff --git a/spec/requests/api/graphql/mutations/design_management/delete_spec.rb b/spec/requests/api/graphql/mutations/design_management/delete_spec.rb new file mode 100644 index 00000000000..9ce2313f1e0 --- /dev/null +++ b/spec/requests/api/graphql/mutations/design_management/delete_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "deleting designs" do + include GraphqlHelpers + include DesignManagementTestHelpers + + let(:developer) { create(:user) } + let(:current_user) { developer } + let(:issue) { create(:issue) } + let(:project) { issue.project } + let(:designs) { create_designs } + let(:variables) { {} } + + let(:mutation) do + input = { + project_path: project.full_path, + iid: issue.iid, + filenames: designs.map(&:filename) + }.merge(variables) + graphql_mutation(:design_management_delete, input) + end + + let(:mutation_response) { graphql_mutation_response(:design_management_delete) } + + def mutate! + post_graphql_mutation(mutation, current_user: current_user) + end + + before do + # TODO these tests are being temporarily skipped unless run in EE, + # as we are in the process of moving Design Management to FOSS in 13.0 + # in steps. In the current step the services have not yet been moved, + # which are used by this mutation. + # + # See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283. + skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee? + + enable_design_management + + project.add_developer(developer) + end + + shared_examples 'a failed request' do + let(:the_error) { be_present } + + it 'reports an error' do + mutate! + + expect(graphql_errors).to include(a_hash_including('message' => the_error)) + end + end + + context 'the designs list is empty' do + it_behaves_like 'a failed request' do + let(:designs) { [] } + let(:the_error) { a_string_matching %r/was provided invalid value/ } + end + end + + context 'the designs list contains filenames we cannot find' do + it_behaves_like 'a failed request' do + let(:designs) { %w/foo bar baz/.map { |fn| OpenStruct.new(filename: fn) } } + let(:the_error) { a_string_matching %r/filenames were not found/ } + end + end + + context 'the current user does not have developer access' do + it_behaves_like 'a failed request' do + let(:current_user) { create(:user) } + let(:the_error) { a_string_matching %r/you don't have permission/ } + end + end + + context "when the issue does not exist" do + it_behaves_like 'a failed request' do + let(:variables) { { iid: "1234567890" } } + let(:the_error) { a_string_matching %r/does not exist/ } + end + end + + context "when saving the designs raises an error" do + let(:designs) { create_designs(1) } + + it "responds with errors" do + expect_next_instance_of(::DesignManagement::DeleteDesignsService) do |service| + expect(service) + .to receive(:execute) + .and_return({ status: :error, message: "Something went wrong" }) + end + + mutate! + + expect(mutation_response).to include('errors' => include(eq "Something went wrong")) + end + end + + context 'one of the designs is already deleted' do + let(:designs) do + create_designs(2).push(create(:design, :with_file, deleted: true, issue: issue)) + end + + it 'reports an error' do + mutate! + + expect(graphql_errors).to be_present + end + end + + context 'when the user names designs to delete' do + before do + create_designs(1) + end + + let!(:designs) { create_designs(2) } + + it 'deletes the designs' do + expect { mutate! } + .to change { issue.reset.designs.current.count }.from(3).to(1) + end + + it 'has no errors' do + mutate! + + expect(mutation_response).to include('errors' => be_empty) + end + end + + private + + def create_designs(how_many = 2) + create_list(:design, how_many, :with_file, issue: issue) + end +end diff --git a/spec/requests/api/graphql/mutations/design_management/upload_spec.rb b/spec/requests/api/graphql/mutations/design_management/upload_spec.rb new file mode 100644 index 00000000000..060e37f559d --- /dev/null +++ b/spec/requests/api/graphql/mutations/design_management/upload_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true +require "spec_helper" + +describe "uploading designs" do + include GraphqlHelpers + include DesignManagementTestHelpers + include WorkhorseHelpers + + let(:current_user) { create(:user) } + let(:issue) { create(:issue) } + let(:project) { issue.project } + let(:files) { [fixture_file_upload("spec/fixtures/dk.png")] } + let(:variables) { {} } + + let(:mutation) do + input = { + project_path: project.full_path, + iid: issue.iid, + files: files + }.merge(variables) + graphql_mutation(:design_management_upload, input) + end + + let(:mutation_response) { graphql_mutation_response(:design_management_upload) } + + before do + # TODO these tests are being temporarily skipped unless run in EE, + # as we are in the process of moving Design Management to FOSS in 13.0 + # in steps. In the current step the services have not yet been moved, + # which are used by this mutation. + # + # See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283. + skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee? + + enable_design_management + + project.add_developer(current_user) + end + + it "returns an error if the user is not allowed to upload designs" do + post_graphql_mutation(mutation, current_user: create(:user)) + + expect(graphql_errors).to be_present + end + + it "succeeds (backward compatibility)" do + post_graphql_mutation(mutation, current_user: current_user) + + expect(graphql_errors).not_to be_present + end + + it 'succeeds' do + file_path_in_params = ['designManagementUploadInput', 'files', 0] + params = mutation_to_apollo_uploads_param(mutation, files: [file_path_in_params]) + + workhorse_post_with_file(api('/', current_user, version: 'graphql'), + params: params, + file_key: '1' + ) + + expect(graphql_errors).not_to be_present + end + + it "responds with the created designs" do + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response).to include( + "designs" => a_collection_containing_exactly( + a_hash_including("filename" => "dk.png") + ) + ) + end + + it "can respond with skipped designs" do + 2.times do + post_graphql_mutation(mutation, current_user: current_user) + files.each(&:rewind) + end + + expect(mutation_response).to include( + "skippedDesigns" => a_collection_containing_exactly( + a_hash_including("filename" => "dk.png") + ) + ) + end + + context "when the issue does not exist" do + let(:variables) { { iid: "123" } } + + it "returns an error" do + post_graphql_mutation(mutation, current_user: create(:user)) + + expect(graphql_errors).not_to be_empty + end + end + + context "when saving the designs raises an error" do + it "responds with errors" do + expect_next_instance_of(::DesignManagement::SaveDesignsService) do |service| + expect(service).to receive(:execute).and_return({ status: :error, message: "Something went wrong" }) + end + + post_graphql_mutation(mutation, current_user: current_user) + expect(mutation_response["errors"].first).to eq("Something went wrong") + end + end +end diff --git a/spec/requests/api/graphql/project/issue/design_collection/version_spec.rb b/spec/requests/api/graphql/project/issue/design_collection/version_spec.rb new file mode 100644 index 00000000000..04f445b4318 --- /dev/null +++ b/spec/requests/api/graphql/project/issue/design_collection/version_spec.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Query.project(fullPath).issue(iid).designCollection.version(sha)' do + include GraphqlHelpers + include DesignManagementTestHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:developer) { create(:user) } + let_it_be(:stranger) { create(:user) } + let_it_be(:old_version) do + create(:design_version, issue: issue, + created_designs: create_list(:design, 3, issue: issue)) + end + let_it_be(:version) do + create(:design_version, issue: issue, + modified_designs: old_version.designs, + created_designs: create_list(:design, 2, issue: issue)) + end + + let(:current_user) { developer } + + def query(vq = version_fields) + graphql_query_for(:project, { fullPath: project.full_path }, + query_graphql_field(:issue, { iid: issue.iid.to_s }, + query_graphql_field(:design_collection, nil, + query_graphql_field(:version, { sha: version.sha }, vq)))) + end + + let(:post_query) { post_graphql(query, current_user: current_user) } + let(:path_prefix) { %w[project issue designCollection version] } + + let(:data) { graphql_data.dig(*path) } + + before do + enable_design_management + project.add_developer(developer) + end + + describe 'scalar fields' do + let(:path) { path_prefix } + let(:version_fields) { query_graphql_field(:sha) } + + before do + post_query + end + + { id: ->(x) { x.to_global_id.to_s }, sha: ->(x) { x.sha } }.each do |field, value| + describe ".#{field}" do + let(:version_fields) { query_graphql_field(field) } + + it "retrieves the #{field}" do + expect(data).to match(a_hash_including(field.to_s => value[version])) + end + end + end + end + + describe 'design_at_version' do + let(:path) { path_prefix + %w[designAtVersion] } + let(:design) { issue.designs.visible_at_version(version).to_a.sample } + let(:design_at_version) { build(:design_at_version, design: design, version: version) } + + let(:version_fields) do + query_graphql_field(:design_at_version, dav_params, 'id filename') + end + + shared_examples :finds_dav do + it 'finds all the designs as of the given version' do + post_query + + expect(data).to match( + a_hash_including( + 'id' => global_id_of(design_at_version), + 'filename' => design.filename + )) + end + + context 'when the current_user is not authorized' do + let(:current_user) { stranger } + + it 'returns nil' do + post_query + + expect(data).to be_nil + end + end + end + + context 'by ID' do + let(:dav_params) { { id: global_id_of(design_at_version) } } + + include_examples :finds_dav + end + + context 'by filename' do + let(:dav_params) { { filename: design.filename } } + + include_examples :finds_dav + end + + context 'by design_id' do + let(:dav_params) { { design_id: global_id_of(design) } } + + include_examples :finds_dav + end + end + + describe 'designs_at_version' do + let(:path) { path_prefix + %w[designsAtVersion edges] } + let(:version_fields) do + query_graphql_field(:designs_at_version, dav_params, 'edges { node { id filename } }') + end + + let(:dav_params) { nil } + + let(:results) do + issue.designs.visible_at_version(version).map do |d| + dav = build(:design_at_version, design: d, version: version) + { 'id' => global_id_of(dav), 'filename' => d.filename } + end + end + + it 'finds all the designs as of the given version' do + post_query + + expect(data.pluck('node')).to match_array(results) + end + + describe 'filtering' do + let(:designs) { issue.designs.sample(3) } + let(:filenames) { designs.map(&:filename) } + let(:ids) do + designs.map { |d| global_id_of(build(:design_at_version, design: d, version: version)) } + end + + before do + post_query + end + + describe 'by filename' do + let(:dav_params) { { filenames: filenames } } + + it 'finds the designs by filename' do + expect(data.map { |e| e.dig('node', 'id') }).to match_array(ids) + end + end + + describe 'by design-id' do + let(:dav_params) { { ids: designs.map { |d| global_id_of(d) } } } + + it 'finds the designs by id' do + expect(data.map { |e| e.dig('node', 'filename') }).to match_array(filenames) + end + end + end + + describe 'pagination' do + let(:end_cursor) { graphql_data_at(*path_prefix, :designs_at_version, :page_info, :end_cursor) } + + let(:ids) do + ::DesignManagement::Design.visible_at_version(version).order(:id).map do |d| + global_id_of(build(:design_at_version, design: d, version: version)) + end + end + + let(:version_fields) do + query_graphql_field(:designs_at_version, { first: 2 }, fields) + end + + let(:cursored_query) do + frag = query_graphql_field(:designs_at_version, { after: end_cursor }, fields) + query(frag) + end + + let(:fields) { ['pageInfo { endCursor }', 'edges { node { id } }'] } + + def response_values(data = graphql_data) + data.dig(*path).map { |e| e.dig('node', 'id') } + end + + it 'sorts designs for reliable pagination' do + post_graphql(query, current_user: current_user) + + expect(response_values).to match_array(ids.take(2)) + + post_graphql(cursored_query, current_user: current_user) + + new_data = Gitlab::Json.parse(response.body).fetch('data') + + expect(response_values(new_data)).to match_array(ids.drop(2)) + end + end + end + + describe 'designs' do + let(:path) { path_prefix + %w[designs edges] } + let(:version_fields) do + query_graphql_field(:designs, nil, 'edges { node { id filename } }') + end + + let(:results) do + version.designs.map do |design| + { 'id' => global_id_of(design), 'filename' => design.filename } + end + end + + it 'finds all the designs as of the given version' do + post_query + + expect(data.pluck('node')).to match_array(results) + end + end +end diff --git a/spec/requests/api/graphql/project/issue/design_collection/versions_spec.rb b/spec/requests/api/graphql/project/issue/design_collection/versions_spec.rb new file mode 100644 index 00000000000..18787bf925d --- /dev/null +++ b/spec/requests/api/graphql/project/issue/design_collection/versions_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Getting versions related to an issue' do + include GraphqlHelpers + include DesignManagementTestHelpers + + let_it_be(:issue) { create(:issue) } + + let_it_be(:version_a) do + create(:design_version, issue: issue) + end + let_it_be(:version_b) do + create(:design_version, issue: issue) + end + let_it_be(:version_c) do + create(:design_version, issue: issue) + end + let_it_be(:version_d) do + create(:design_version, issue: issue) + end + + let_it_be(:owner) { issue.project.owner } + + def version_query(params = version_params) + query_graphql_field(:versions, params, version_query_fields) + end + + let(:version_params) { nil } + + let(:version_query_fields) { ['edges { node { sha } }'] } + + let(:project) { issue.project } + let(:current_user) { owner } + + let(:query) { make_query } + + def make_query(vq = version_query) + graphql_query_for(:project, { fullPath: project.full_path }, + query_graphql_field(:issue, { iid: issue.iid.to_s }, + query_graphql_field(:design_collection, {}, vq))) + end + + let(:design_collection) do + graphql_data_at(:project, :issue, :design_collection) + end + + def response_values(data = graphql_data, key = 'sha') + path = %w[project issue designCollection versions edges] + data.dig(*path).map { |e| e.dig('node', key) } + end + + before do + enable_design_management + end + + it 'returns the design filename' do + post_graphql(query, current_user: current_user) + + expect(response_values).to match_array([version_a, version_b, version_c, version_d].map(&:sha)) + end + + describe 'filter by sha' do + let(:sha) { version_b.sha } + + let(:version_params) { { earlier_or_equal_to_sha: sha } } + + it 'finds only those versions at or before the given cut-off' do + post_graphql(query, current_user: current_user) + + expect(response_values).to contain_exactly(version_a.sha, version_b.sha) + end + end + + describe 'filter by id' do + let(:id) { global_id_of(version_c) } + + let(:version_params) { { earlier_or_equal_to_id: id } } + + it 'finds only those versions at or before the given cut-off' do + post_graphql(query, current_user: current_user) + + expect(response_values).to contain_exactly(version_a.sha, version_b.sha, version_c.sha) + end + end + + describe 'pagination' do + let(:end_cursor) { design_collection.dig('versions', 'pageInfo', 'endCursor') } + + let(:ids) { issue.design_collection.versions.ordered.map(&:sha) } + + let(:query) { make_query(version_query(first: 2)) } + + let(:cursored_query) do + make_query(version_query(after: end_cursor)) + end + + let(:version_query_fields) { ['pageInfo { endCursor }', 'edges { node { sha } }'] } + + it 'sorts designs for reliable pagination' do + post_graphql(query, current_user: current_user) + + expect(response_values).to match_array(ids.take(2)) + + post_graphql(cursored_query, current_user: current_user) + + new_data = Gitlab::Json.parse(response.body).fetch('data') + + expect(response_values(new_data)).to match_array(ids.drop(2)) + end + end +end diff --git a/spec/requests/api/graphql/project/issue/designs/designs_spec.rb b/spec/requests/api/graphql/project/issue/designs/designs_spec.rb new file mode 100644 index 00000000000..105ee9daf0c --- /dev/null +++ b/spec/requests/api/graphql/project/issue/designs/designs_spec.rb @@ -0,0 +1,398 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Getting designs related to an issue' do + include GraphqlHelpers + include DesignManagementTestHelpers + + before_all do + # TODO these tests are being temporarily skipped unless run in EE, + # as we are in the process of moving Design Management to FOSS in 13.0 + # in steps. In the current step the services have not yet been moved, + # which are used by the `:with_smaller_image_versions` factory trait. + # + # See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283. + skip 'See https://gitlab.com/gitlab-org/gitlab/-/issues/212566#note_327724283' unless Gitlab.ee? + end + + let_it_be(:design) { create(:design, :with_smaller_image_versions, versions_count: 1) } + let_it_be(:current_user) { design.project.owner } + let(:design_query) do + <<~NODE + designs { + edges { + node { + id + filename + fullPath + event + image + imageV432x230 + } + } + } + NODE + end + let(:issue) { design.issue } + let(:project) { issue.project } + let(:query) { make_query } + let(:design_collection) do + graphql_data_at(:project, :issue, :design_collection) + end + let(:design_response) do + design_collection.dig('designs', 'edges').first['node'] + end + + def make_query(dq = design_query) + designs_field = query_graphql_field(:design_collection, {}, dq) + issue_field = query_graphql_field(:issue, { iid: issue.iid.to_s }, designs_field) + + graphql_query_for(:project, { fullPath: project.full_path }, issue_field) + end + + def design_image_url(design, ref: nil, size: nil) + Gitlab::UrlBuilder.build(design, ref: ref, size: size) + end + + context 'when the feature is available' do + before do + enable_design_management + end + + it 'returns the design properties correctly' do + version_sha = design.versions.first.sha + + post_graphql(query, current_user: current_user) + + expect(design_response).to eq( + 'id' => design.to_global_id.to_s, + 'event' => 'CREATION', + 'fullPath' => design.full_path, + 'filename' => design.filename, + 'image' => design_image_url(design, ref: version_sha), + 'imageV432x230' => design_image_url(design, ref: version_sha, size: :v432x230) + ) + end + + context 'when the v432x230-sized design image has not been processed' do + before do + allow_next_instance_of(DesignManagement::DesignV432x230Uploader) do |uploader| + allow(uploader).to receive(:file).and_return(nil) + end + end + + it 'returns nil for the v432x230-sized design image' do + post_graphql(query, current_user: current_user) + + expect(design_response['imageV432x230']).to be_nil + end + end + + describe 'pagination' do + before do + create_list(:design, 5, :with_file, issue: issue) + project.add_developer(current_user) + post_graphql(query, current_user: current_user) + end + + let(:issue) { create(:issue) } + + let(:end_cursor) { design_collection.dig('designs', 'pageInfo', 'endCursor') } + + let(:ids) { issue.designs.order(:id).map { |d| global_id_of(d) } } + + let(:query) { make_query(designs_fragment(first: 2)) } + + let(:design_query_fields) { 'pageInfo { endCursor } edges { node { id } }' } + + let(:cursored_query) do + make_query(designs_fragment(after: end_cursor)) + end + + def designs_fragment(params) + query_graphql_field(:designs, params, design_query_fields) + end + + def response_ids(data = graphql_data) + path = %w[project issue designCollection designs edges] + data.dig(*path).map { |e| e.dig('node', 'id') } + end + + it 'sorts designs for reliable pagination' do + expect(response_ids).to match_array(ids.take(2)) + + post_graphql(cursored_query, current_user: current_user) + + new_data = Gitlab::Json.parse(response.body).fetch('data') + + expect(response_ids(new_data)).to match_array(ids.drop(2)) + end + end + + context 'with versions' do + let_it_be(:version) { design.versions.take } + let(:design_query) do + <<~NODE + designs { + edges { + node { + filename + versions { + edges { + node { + id + sha + } + } + } + } + } + } + NODE + end + + it 'includes the version id' do + post_graphql(query, current_user: current_user) + + version_id = design_response['versions']['edges'].first['node']['id'] + + expect(version_id).to eq(version.to_global_id.to_s) + end + + it 'includes the version sha' do + post_graphql(query, current_user: current_user) + + version_sha = design_response['versions']['edges'].first['node']['sha'] + + expect(version_sha).to eq(version.sha) + end + end + + describe 'viewing a design board at a particular version' do + let_it_be(:issue) { design.issue } + let_it_be(:second_design, reload: true) { create(:design, :with_smaller_image_versions, issue: issue, versions_count: 1) } + let_it_be(:deleted_design) { create(:design, :with_versions, issue: issue, deleted: true, versions_count: 1) } + let(:all_versions) { issue.design_versions.ordered.reverse } + let(:design_query) do + <<~NODE + designs(atVersion: "#{version.to_global_id}") { + edges { + node { + id + image + imageV432x230 + event + versions { + edges { + node { + id + } + } + } + } + } + } + NODE + end + let(:design_response) do + design_collection['designs']['edges'] + end + + def global_id(object) + object.to_global_id.to_s + end + + # Filters just design nodes from the larger `design_response` + def design_nodes + design_response.map do |response| + response['node'] + end + end + + # Filters just version nodes from the larger `design_response` + def version_nodes + design_response.map do |response| + response.dig('node', 'versions', 'edges') + end + end + + context 'viewing the original version, when one design was created' do + let(:version) { all_versions.first } + + before do + post_graphql(query, current_user: current_user) + end + + it 'only returns the first design' do + expect(design_nodes).to contain_exactly( + a_hash_including('id' => global_id(design)) + ) + end + + it 'returns the correct full-sized design image' do + expect(design_nodes).to contain_exactly( + a_hash_including('image' => design_image_url(design, ref: version.sha)) + ) + end + + it 'returns the correct v432x230-sized design image' do + expect(design_nodes).to contain_exactly( + a_hash_including('imageV432x230' => design_image_url(design, ref: version.sha, size: :v432x230)) + ) + end + + it 'returns the correct event for the design in this version' do + expect(design_nodes).to contain_exactly( + a_hash_including('event' => 'CREATION') + ) + end + + it 'only returns one version record for the design (the original version)' do + expect(version_nodes).to eq([ + [{ 'node' => { 'id' => global_id(version) } }] + ]) + end + end + + context 'viewing the second version, when one design was created' do + let(:version) { all_versions.second } + + before do + post_graphql(query, current_user: current_user) + end + + it 'only returns the first two designs' do + expect(design_nodes).to contain_exactly( + a_hash_including('id' => global_id(design)), + a_hash_including('id' => global_id(second_design)) + ) + end + + it 'returns the correct full-sized design images' do + expect(design_nodes).to contain_exactly( + a_hash_including('image' => design_image_url(design, ref: version.sha)), + a_hash_including('image' => design_image_url(second_design, ref: version.sha)) + ) + end + + it 'returns the correct v432x230-sized design images' do + expect(design_nodes).to contain_exactly( + a_hash_including('imageV432x230' => design_image_url(design, ref: version.sha, size: :v432x230)), + a_hash_including('imageV432x230' => design_image_url(second_design, ref: version.sha, size: :v432x230)) + ) + end + + it 'returns the correct events for the designs in this version' do + expect(design_nodes).to contain_exactly( + a_hash_including('event' => 'NONE'), + a_hash_including('event' => 'CREATION') + ) + end + + it 'returns the correct versions records for both designs' do + expect(version_nodes).to eq([ + [{ 'node' => { 'id' => global_id(design.versions.first) } }], + [{ 'node' => { 'id' => global_id(second_design.versions.first) } }] + ]) + end + end + + context 'viewing the last version, when one design was deleted and one was updated' do + let(:version) { all_versions.last } + let!(:second_design_update) do + create(:design_action, :with_image_v432x230, design: second_design, version: version, event: 'modification') + end + + before do + post_graphql(query, current_user: current_user) + end + + it 'does not include the deleted design' do + # The design does exist in the version + expect(version.designs).to include(deleted_design) + + # But the GraphQL API does not include it in these results + expect(design_nodes).to contain_exactly( + a_hash_including('id' => global_id(design)), + a_hash_including('id' => global_id(second_design)) + ) + end + + it 'returns the correct full-sized design images' do + expect(design_nodes).to contain_exactly( + a_hash_including('image' => design_image_url(design, ref: version.sha)), + a_hash_including('image' => design_image_url(second_design, ref: version.sha)) + ) + end + + it 'returns the correct v432x230-sized design images' do + expect(design_nodes).to contain_exactly( + a_hash_including('imageV432x230' => design_image_url(design, ref: version.sha, size: :v432x230)), + a_hash_including('imageV432x230' => design_image_url(second_design, ref: version.sha, size: :v432x230)) + ) + end + + it 'returns the correct events for the designs in this version' do + expect(design_nodes).to contain_exactly( + a_hash_including('event' => 'NONE'), + a_hash_including('event' => 'MODIFICATION') + ) + end + + it 'returns all versions records for the designs' do + expect(version_nodes).to eq([ + [ + { 'node' => { 'id' => global_id(design.versions.first) } } + ], + [ + { 'node' => { 'id' => global_id(second_design.versions.second) } }, + { 'node' => { 'id' => global_id(second_design.versions.first) } } + ] + ]) + end + end + end + + describe 'a design with note annotations' do + let_it_be(:note) { create(:diff_note_on_design, noteable: design) } + + let(:design_query) do + <<~NODE + designs { + edges { + node { + notesCount + notes { + edges { + node { + id + } + } + } + } + } + } + NODE + end + + let(:design_response) do + design_collection['designs']['edges'].first['node'] + end + + before do + post_graphql(query, current_user: current_user) + end + + it 'returns the notes for the design' do + expect(design_response.dig('notes', 'edges')).to eq( + ['node' => { 'id' => note.to_global_id.to_s }] + ) + end + + it 'returns a note_count for the design' do + expect(design_response['notesCount']).to eq(1) + end + end + end +end diff --git a/spec/requests/api/graphql/project/issue/designs/notes_spec.rb b/spec/requests/api/graphql/project/issue/designs/notes_spec.rb new file mode 100644 index 00000000000..0207bb9123a --- /dev/null +++ b/spec/requests/api/graphql/project/issue/designs/notes_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Getting designs related to an issue' do + include GraphqlHelpers + include DesignManagementTestHelpers + + let_it_be(:project) { create(:project, :public) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:design) { create(:design, :with_file, versions_count: 1, issue: issue) } + let_it_be(:current_user) { project.owner } + let_it_be(:note) { create(:diff_note_on_design, noteable: design, project: project) } + + before do + enable_design_management + + note + end + + it_behaves_like 'a working graphql query' do + before do + post_graphql(query, current_user: current_user) + end + end + + it 'is not too deep for anonymous users' do + note_fields = <<~FIELDS + id + author { name } + FIELDS + + post_graphql(query(note_fields), current_user: nil) + + designs_data = graphql_data['project']['issue']['designs']['designs'] + design_data = designs_data['edges'].first['node'] + note_data = design_data['notes']['edges'].first['node'] + + expect(note_data['id']).to eq(note.to_global_id.to_s) + end + + def query(note_fields = all_graphql_fields_for(Note)) + design_node = <<~NODE + designs { + edges { + node { + notes { + edges { + node { + #{note_fields} + } + } + } + } + } + } + NODE + graphql_query_for( + 'project', + { 'fullPath' => design.project.full_path }, + query_graphql_field( + 'issue', + { iid: design.issue.iid.to_s }, + query_graphql_field( + 'designs', {}, design_node + ) + ) + ) + end +end diff --git a/spec/requests/api/graphql/project/issue_spec.rb b/spec/requests/api/graphql/project/issue_spec.rb new file mode 100644 index 00000000000..92d2f9d0d31 --- /dev/null +++ b/spec/requests/api/graphql/project/issue_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Query.project(fullPath).issue(iid)' do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:issue_b) { create(:issue, project: project) } + let_it_be(:developer) { create(:user) } + let(:current_user) { developer } + + let_it_be(:project_params) { { 'fullPath' => project.full_path } } + let_it_be(:issue_params) { { 'iid' => issue.iid.to_s } } + let_it_be(:issue_fields) { 'title' } + + let(:query) do + graphql_query_for('project', project_params, project_fields) + end + + let(:project_fields) do + query_graphql_field(:issue, issue_params, issue_fields) + end + + shared_examples 'being able to fetch a design-like object by ID' do + let(:design) { design_a } + let(:path) { %w[project issue designCollection] + [GraphqlHelpers.fieldnamerize(object_field_name)] } + + let(:design_fields) do + [ + query_graphql_field(:filename), + query_graphql_field(:project, nil, query_graphql_field(:id)) + ] + end + + let(:design_collection_fields) do + query_graphql_field(object_field_name, object_params, object_fields) + end + + let(:object_fields) { design_fields } + + context 'the ID is passed' do + let(:object_params) { { id: global_id_of(object) } } + let(:result_fields) { {} } + + let(:expected_fields) do + result_fields.merge({ 'filename' => design.filename, 'project' => id_hash(project) }) + end + + it 'retrieves the object' do + post_query + + data = graphql_data.dig(*path) + + expect(data).to match(a_hash_including(expected_fields)) + end + + context 'the user is unauthorized' do + let(:current_user) { create(:user) } + + it_behaves_like 'a failure to find anything' + end + end + + context 'without parameters' do + let(:object_params) { nil } + + it 'raises an error' do + post_query + + expect(graphql_errors).to include(no_argument_error) + end + end + + context 'attempting to retrieve an object from a different issue' do + let(:object_params) { { id: global_id_of(object_on_other_issue) } } + + it_behaves_like 'a failure to find anything' + end + end + + before do + project.add_developer(developer) + end + + let(:post_query) { post_graphql(query, current_user: current_user) } + + describe '.designCollection' do + include DesignManagementTestHelpers + + let_it_be(:design_a) { create(:design, issue: issue) } + let_it_be(:version_a) { create(:design_version, issue: issue, created_designs: [design_a]) } + + let(:issue_fields) do + query_graphql_field(:design_collection, dc_params, design_collection_fields) + end + + let(:dc_params) { nil } + let(:design_collection_fields) { nil } + + before do + enable_design_management + end + + describe '.design' do + let(:object) { design } + let(:object_field_name) { :design } + + let(:no_argument_error) do + custom_graphql_error(path, a_string_matching(%r/id or filename/)) + end + + let_it_be(:object_on_other_issue) { create(:design, issue: issue_b) } + + it_behaves_like 'being able to fetch a design-like object by ID' + + it_behaves_like 'being able to fetch a design-like object by ID' do + let(:object_params) { { filename: design.filename } } + end + end + + describe '.version' do + let(:version) { version_a } + let(:path) { %w[project issue designCollection version] } + + let(:design_collection_fields) do + query_graphql_field(:version, version_params, 'id sha') + end + + context 'no parameters' do + let(:version_params) { nil } + + it 'raises an error' do + post_query + + expect(graphql_errors).to include(custom_graphql_error(path, a_string_matching(%r/id or sha/))) + end + end + + shared_examples 'a successful query for a version' do + it 'finds the version' do + post_query + + data = graphql_data.dig(*path) + + expect(data).to match( + a_hash_including('id' => global_id_of(version), + 'sha' => version.sha) + ) + end + end + + context '(sha: STRING_TYPE)' do + let(:version_params) { { sha: version.sha } } + + it_behaves_like 'a successful query for a version' + end + + context '(id: ID_TYPE)' do + let(:version_params) { { id: global_id_of(version) } } + + it_behaves_like 'a successful query for a version' + end + end + + describe '.designAtVersion' do + it_behaves_like 'being able to fetch a design-like object by ID' do + let(:object) { build(:design_at_version, design: design, version: version) } + let(:object_field_name) { :design_at_version } + + let(:version) { version_a } + + let(:result_fields) { { 'version' => id_hash(version) } } + let(:object_fields) do + design_fields + [query_graphql_field(:version, nil, query_graphql_field(:id))] + end + + let(:no_argument_error) { missing_required_argument(path, :id) } + + let(:object_on_other_issue) { build(:design_at_version, issue: issue_b) } + end + end + end + + def id_hash(object) + a_hash_including('id' => global_id_of(object)) + end +end diff --git a/spec/requests/api/graphql/query_spec.rb b/spec/requests/api/graphql/query_spec.rb new file mode 100644 index 00000000000..26b4c6eafd7 --- /dev/null +++ b/spec/requests/api/graphql/query_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Query' do + include GraphqlHelpers + + let_it_be(:project) { create(:project) } + let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:developer) { create(:user) } + let(:current_user) { developer } + + describe '.designManagement' do + include DesignManagementTestHelpers + + let_it_be(:version) { create(:design_version, issue: issue) } + let_it_be(:design) { version.designs.first } + let(:query_result) { graphql_data.dig(*path) } + let(:query) { graphql_query_for(:design_management, nil, dm_fields) } + + before do + enable_design_management + project.add_developer(developer) + post_graphql(query, current_user: current_user) + end + + shared_examples 'a query that needs authorization' do + context 'the current user is not able to read designs' do + let(:current_user) { create(:user) } + + it 'does not retrieve the record' do + expect(query_result).to be_nil + end + + it 'raises an error' do + expect(graphql_errors).to include( + a_hash_including('message' => a_string_matching(%r{you don't have permission})) + ) + end + end + end + + describe '.version' do + let(:path) { %w[designManagement version] } + + let(:dm_fields) do + query_graphql_field(:version, { 'id' => global_id_of(version) }, 'id sha') + end + + it_behaves_like 'a working graphql query' + it_behaves_like 'a query that needs authorization' + + context 'the current user is able to read designs' do + it 'fetches the expected data' do + expect(query_result).to eq('id' => global_id_of(version), 'sha' => version.sha) + end + end + end + + describe '.designAtVersion' do + let_it_be(:design_at_version) do + ::DesignManagement::DesignAtVersion.new(design: design, version: version) + end + + let(:path) { %w[designManagement designAtVersion] } + + let(:dm_fields) do + query_graphql_field(:design_at_version, { 'id' => global_id_of(design_at_version) }, <<~FIELDS) + id + filename + version { id sha } + design { id } + issue { title iid } + project { id fullPath } + FIELDS + end + + it_behaves_like 'a working graphql query' + it_behaves_like 'a query that needs authorization' + + context 'the current user is able to read designs' do + it 'fetches the expected data, including the correct associations' do + expect(query_result).to eq( + 'id' => global_id_of(design_at_version), + 'filename' => design_at_version.design.filename, + 'version' => { 'id' => global_id_of(version), 'sha' => version.sha }, + 'design' => { 'id' => global_id_of(design) }, + 'issue' => { 'title' => issue.title, 'iid' => issue.iid.to_s }, + 'project' => { 'id' => global_id_of(project), 'fullPath' => project.full_path } + ) + end + end + end + end +end diff --git a/spec/requests/api/internal/base_spec.rb b/spec/requests/api/internal/base_spec.rb index ff151e891f4..684f0329909 100644 --- a/spec/requests/api/internal/base_spec.rb +++ b/spec/requests/api/internal/base_spec.rb @@ -917,6 +917,23 @@ describe API::Internal::Base do expect(json_response['status']).to be_falsy end end + + context 'for design repositories' do + let(:gl_repository) { Gitlab::GlRepository::DESIGN.identifier_for_container(project) } + + it 'does not allow access' do + post(api('/internal/allowed'), + params: { + key_id: key.id, + project: project.full_path, + gl_repository: gl_repository, + secret_token: secret_token, + protocol: 'ssh' + }) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end end describe 'POST /internal/post_receive', :clean_gitlab_redis_shared_state do diff --git a/spec/requests/api/project_templates_spec.rb b/spec/requests/api/project_templates_spec.rb index 3d86fbfe92d..caeb465080e 100644 --- a/spec/requests/api/project_templates_spec.rb +++ b/spec/requests/api/project_templates_spec.rb @@ -7,16 +7,24 @@ describe API::ProjectTemplates do let_it_be(:private_project) { create(:project, :private) } let_it_be(:developer) { create(:user) } + let(:url_encoded_path) { "#{public_project.namespace.path}%2F#{public_project.path}" } + before do private_project.add_developer(developer) end - describe 'GET /projects/:id/templates/:type' do - it 'accepts project paths with dots' do - get api("/projects/#{public_project.namespace.path}%2F#{public_project.path}/templates/dockerfiles") + shared_examples 'accepts project paths with dots' do + it do + subject expect(response).to have_gitlab_http_status(:ok) end + end + + describe 'GET /projects/:id/templates/:type' do + it_behaves_like 'accepts project paths with dots' do + subject { get api("/projects/#{url_encoded_path}/templates/dockerfiles") } + end it 'returns dockerfiles' do get api("/projects/#{public_project.id}/templates/dockerfiles") @@ -81,6 +89,10 @@ describe API::ProjectTemplates do expect(response).to have_gitlab_http_status(:ok) expect(response).to match_response_schema('public_api/v4/template_list') end + + it_behaves_like 'accepts project paths with dots' do + subject { get api("/projects/#{url_encoded_path}/templates/licenses") } + end end describe 'GET /projects/:id/templates/:type/:key' do @@ -150,6 +162,10 @@ describe API::ProjectTemplates do expect(response).to match_response_schema('public_api/v4/license') end + it_behaves_like 'accepts project paths with dots' do + subject { get api("/projects/#{url_encoded_path}/templates/gitlab_ci_ymls/Android") } + end + shared_examples 'path traversal attempt' do |template_type| it 'rejects invalid filenames' do get api("/projects/#{public_project.id}/templates/#{template_type}/%2e%2e%2fPython%2ea") @@ -179,5 +195,9 @@ describe API::ProjectTemplates do expect(content).to include('Project Placeholder') expect(content).to include("Copyright (C) #{Time.now.year} Fullname Placeholder") end + + it_behaves_like 'accepts project paths with dots' do + subject { get api("/projects/#{url_encoded_path}/templates/licenses/mit") } + end end end diff --git a/spec/support/shared_contexts/design_management_shared_contexts.rb b/spec/support/shared_contexts/design_management_shared_contexts.rb new file mode 100644 index 00000000000..2866effb3a8 --- /dev/null +++ b/spec/support/shared_contexts/design_management_shared_contexts.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +shared_context 'four designs in three versions' do + include DesignManagementTestHelpers + + let_it_be(:issue) { create(:issue) } + let_it_be(:project) { issue.project } + let_it_be(:authorized_user) { create(:user) } + + let_it_be(:design_a) { create(:design, issue: issue) } + let_it_be(:design_b) { create(:design, issue: issue) } + let_it_be(:design_c) { create(:design, issue: issue) } + let_it_be(:design_d) { create(:design, issue: issue) } + + let_it_be(:first_version) do + create(:design_version, issue: issue, + created_designs: [design_a], + modified_designs: [], + deleted_designs: []) + end + let_it_be(:second_version) do + create(:design_version, issue: issue, + created_designs: [design_b, design_c, design_d], + modified_designs: [design_a], + deleted_designs: []) + end + let_it_be(:third_version) do + create(:design_version, issue: issue, + created_designs: [], + modified_designs: [design_a], + deleted_designs: [design_d]) + end + + before do + enable_design_management + project.add_developer(authorized_user) + end +end diff --git a/spec/support/shared_examples/graphql/design_fields_shared_examples.rb b/spec/support/shared_examples/graphql/design_fields_shared_examples.rb new file mode 100644 index 00000000000..029d7e677da --- /dev/null +++ b/spec/support/shared_examples/graphql/design_fields_shared_examples.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# To use these shared examples, you may define a value in scope named +# `extra_design_fields`, to pass any extra fields in addition to the +# standard design fields. +RSpec.shared_examples 'a GraphQL type with design fields' do + let(:extra_design_fields) { [] } + + it { expect(described_class).to require_graphql_authorizations(:read_design) } + + it 'exposes the expected design fields' do + expected_fields = %i[ + id + project + issue + filename + full_path + image + image_v432x230 + diff_refs + event + notes_count + ] + extra_design_fields + + expect(described_class).to have_graphql_fields(*expected_fields).only + end + + describe '#image' do + let(:schema) { GitlabSchema } + let(:query) { GraphQL::Query.new(schema) } + let(:context) { double('Context', schema: schema, query: query, parent: nil) } + let(:field) { described_class.fields['image'] } + let(:args) { GraphQL::Query::Arguments::NO_ARGS } + let(:instance) do + object = GitlabSchema.sync_lazy(GitlabSchema.object_from_id(object_id)) + object_type.authorized_new(object, query.context) + end + let(:instance_b) do + object_b = GitlabSchema.sync_lazy(GitlabSchema.object_from_id(object_id_b)) + object_type.authorized_new(object_b, query.context) + end + + it 'resolves to the design image URL' do + image = field.resolve_field(instance, args, context) + sha = design.versions.first.sha + url = ::Gitlab::Routing.url_helpers.project_design_management_designs_raw_image_url(design.project, design, sha) + + expect(image).to eq(url) + end + + it 'has better than O(N) peformance', :request_store do + # Assuming designs have been loaded (as they must be), the following + # queries are required: + # For each distinct version: + # - design_management_versions + # (Request store is needed so that each version is fetched only once.) + # For each distinct issue + # - issues + # For each distinct project + # - projects + # - routes + # - namespaces + # Here that total is: + # - 2 x issues + # - 2 x versions + # - 2 x (projects + routes + namespaces) + # = 10 + expect(instance).not_to eq(instance_b) # preload designs themselves. + expect do + image_a = field.resolve_field(instance, args, context) + image_b = field.resolve_field(instance, args, context) + image_c = field.resolve_field(instance_b, args, context) + image_d = field.resolve_field(instance_b, args, context) + expect(image_a).to eq(image_b) + expect(image_c).not_to eq(image_b) + expect(image_c).to eq(image_d) + end.not_to exceed_query_limit(10) + end + end +end diff --git a/spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb b/spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb index aa8979603b6..b0cdc77a378 100644 --- a/spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb +++ b/spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb @@ -49,5 +49,29 @@ RSpec.shared_examples 'a valid diff positionable note' do |factory_on_commit| expect(subject.errors).to have_key(:commit_id) end end + + %i(original_position position change_position).each do |method| + describe "#{method}=" do + it "doesn't accept non-hash JSON passed as a string" do + subject.send(:"#{method}=", "true") + expect(subject.attributes_before_type_cast[method.to_s]).to be(nil) + end + + it "does accept a position hash as a string" do + subject.send(:"#{method}=", position.to_json) + expect(subject.position).to eq(position) + end + + it "doesn't accept an array" do + subject.send(:"#{method}=", ["test"]) + expect(subject.attributes_before_type_cast[method.to_s]).to be(nil) + end + + it "does accept a hash" do + subject.send(:"#{method}=", position.to_h) + expect(subject.position).to eq(position) + end + end + end end end |