summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/assets/javascripts/alert_management/components/alert_details.vue140
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/details.query.graphql1
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue61
-rw-r--r--app/assets/javascripts/monitoring/constants.js11
-rw-r--r--app/assets/javascripts/monitoring/stores/getters.js13
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js21
-rw-r--r--app/assets/javascripts/monitoring/stores/variable_mapping.js3
-rw-r--r--app/assets/javascripts/monitoring/utils.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue38
-rw-r--r--app/assets/stylesheets/pages/alerts_list.scss3
-rw-r--r--app/assets/stylesheets/utilities.scss5
-rw-r--r--app/controllers/concerns/metrics_dashboard.rb1
-rw-r--r--app/graphql/mutations/design_management/base.rb23
-rw-r--r--app/graphql/mutations/design_management/delete.rb66
-rw-r--r--app/graphql/mutations/design_management/upload.rb38
-rw-r--r--app/graphql/resolvers/design_management/design_at_version_resolver.rb46
-rw-r--r--app/graphql/resolvers/design_management/design_resolver.rb57
-rw-r--r--app/graphql/resolvers/design_management/designs_resolver.rb50
-rw-r--r--app/graphql/resolvers/design_management/version/design_at_version_resolver.rb95
-rw-r--r--app/graphql/resolvers/design_management/version/designs_at_version_resolver.rb60
-rw-r--r--app/graphql/resolvers/design_management/version_in_collection_resolver.rb45
-rw-r--r--app/graphql/resolvers/design_management/version_resolver.rb25
-rw-r--r--app/graphql/resolvers/design_management/versions_resolver.rb76
-rw-r--r--app/graphql/types/design_management/design_at_version_type.rb37
-rw-r--r--app/graphql/types/design_management/design_collection_type.rb44
-rw-r--r--app/graphql/types/design_management/design_fields.rb78
-rw-r--r--app/graphql/types/design_management/design_type.rb44
-rw-r--r--app/graphql/types/design_management/design_version_event_enum.rb18
-rw-r--r--app/graphql/types/design_management/version_type.rb37
-rw-r--r--app/graphql/types/design_management_type.rb18
-rw-r--r--app/graphql/types/issue_type.rb8
-rw-r--r--app/graphql/types/mutation_type.rb2
-rw-r--r--app/graphql/types/notes/noteable_type.rb4
-rw-r--r--app/graphql/types/permission_types/issue.rb8
-rw-r--r--app/graphql/types/permission_types/project.rb4
-rw-r--r--app/graphql/types/query_type.rb11
-rw-r--r--app/graphql/types/todo_target_enum.rb1
-rw-r--r--app/helpers/issues_helper.rb7
-rw-r--r--app/models/concerns/diff_positionable_note.rb2
-rw-r--r--changelogs/unreleased/214582-revert-sort-order-change-BE.yml5
-rw-r--r--changelogs/unreleased/216597-drop-uss-pss-env-var.yml5
-rw-r--r--changelogs/unreleased/24525-ide-renaming-issue.yml5
-rw-r--r--changelogs/unreleased/dbodicherla-add-prefix-for-templating-variables.yml5
-rw-r--r--changelogs/unreleased/fj-fix-templates-endpoint-when-project-has-dots.yml5
-rw-r--r--doc/administration/monitoring/prometheus/gitlab_metrics.md22
-rw-r--r--doc/development/ee_features.md2
-rw-r--r--doc/user/analytics/img/repository_analytics_v13_0.pngbin0 -> 91755 bytes
-rw-r--r--doc/user/analytics/index.md2
-rw-r--r--doc/user/analytics/repository_analytics.md40
-rw-r--r--doc/user/clusters/applications.md7
-rw-r--r--lib/api/project_templates.rb10
-rw-r--r--lib/gitlab/kubernetes/helm/parsers/list_v2.rb27
-rw-r--r--lib/gitlab/metrics/samplers/ruby_sampler.rb2
-rw-r--r--locale/gitlab.pot6
-rw-r--r--spec/controllers/concerns/metrics_dashboard_spec.rb6
-rw-r--r--spec/frontend/alert_management/components/alert_management_detail_spec.js39
-rw-r--r--spec/frontend/ide/components/new_dropdown/modal_spec.js27
-rw-r--r--spec/frontend/monitoring/components/variables_section_spec.js4
-rw-r--r--spec/frontend/monitoring/mock_data.js12
-rw-r--r--spec/frontend/monitoring/store/getters_spec.js5
-rw-r--r--spec/frontend/monitoring/utils_spec.js3
-rw-r--r--spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestions_spec.js (renamed from spec/javascripts/vue_shared/components/markdown/suggestions_spec.js)10
-rw-r--r--spec/frontend/vue_shared/components/project_selector/project_selector_spec.js (renamed from spec/javascripts/vue_shared/components/project_selector/project_selector_spec.js)40
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_svg_spec.js27
-rw-r--r--spec/graphql/mutations/design_management/delete_spec.rb153
-rw-r--r--spec/graphql/mutations/design_management/upload_spec.rb144
-rw-r--r--spec/graphql/resolvers/design_management/design_at_version_resolver_spec.rb69
-rw-r--r--spec/graphql/resolvers/design_management/design_resolver_spec.rb88
-rw-r--r--spec/graphql/resolvers/design_management/designs_resolver_spec.rb93
-rw-r--r--spec/graphql/resolvers/design_management/version/design_at_version_resolver_spec.rb93
-rw-r--r--spec/graphql/resolvers/design_management/version/designs_at_version_resolver_spec.rb86
-rw-r--r--spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb64
-rw-r--r--spec/graphql/resolvers/design_management/version_resolver_spec.rb43
-rw-r--r--spec/graphql/resolvers/design_management/versions_resolver_spec.rb117
-rw-r--r--spec/graphql/types/design_management/design_at_version_type_spec.rb16
-rw-r--r--spec/graphql/types/design_management/design_collection_type_spec.rb13
-rw-r--r--spec/graphql/types/design_management/design_type_spec.rb13
-rw-r--r--spec/graphql/types/design_management/design_version_event_enum_spec.rb11
-rw-r--r--spec/graphql/types/design_management/version_type_spec.rb13
-rw-r--r--spec/graphql/types/design_management_type_spec.rb7
-rw-r--r--spec/graphql/types/issue_type_spec.rb3
-rw-r--r--spec/graphql/types/notes/noteable_type_spec.rb1
-rw-r--r--spec/graphql/types/permission_types/issue_spec.rb5
-rw-r--r--spec/graphql/types/permission_types/project_spec.rb2
-rw-r--r--spec/graphql/types/query_type_spec.rb2
-rw-r--r--spec/lib/gitlab/git_access_design_spec.rb9
-rw-r--r--spec/lib/gitlab/kubernetes/helm/parsers/list_v2_spec.rb86
-rw-r--r--spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb16
-rw-r--r--spec/requests/api/graphql/current_user/todos_query_spec.rb7
-rw-r--r--spec/requests/api/graphql/mutations/design_management/delete_spec.rb135
-rw-r--r--spec/requests/api/graphql/mutations/design_management/upload_spec.rb107
-rw-r--r--spec/requests/api/graphql/project/issue/design_collection/version_spec.rb216
-rw-r--r--spec/requests/api/graphql/project/issue/design_collection/versions_spec.rb113
-rw-r--r--spec/requests/api/graphql/project/issue/designs/designs_spec.rb398
-rw-r--r--spec/requests/api/graphql/project/issue/designs/notes_spec.rb70
-rw-r--r--spec/requests/api/graphql/project/issue_spec.rb189
-rw-r--r--spec/requests/api/graphql/query_spec.rb95
-rw-r--r--spec/requests/api/internal/base_spec.rb17
-rw-r--r--spec/requests/api/project_templates_spec.rb26
-rw-r--r--spec/support/shared_contexts/design_management_shared_contexts.rb38
-rw-r--r--spec/support/shared_examples/graphql/design_fields_shared_examples.rb80
-rw-r--r--spec/support/shared_examples/models/diff_positionable_note_shared_examples.rb24
105 files changed, 3903 insertions, 305 deletions
diff --git a/Gemfile b/Gemfile
index 5d3709c8d64..6bf8d63d939 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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">&bull;</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
new file mode 100644
index 00000000000..b70b63a6239
--- /dev/null
+++ b/doc/user/analytics/img/repository_analytics_v13_0.png
Binary files differ
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