diff options
47 files changed, 880 insertions, 85 deletions
diff --git a/.gitlab/ci/frontend.gitlab-ci.yml b/.gitlab/ci/frontend.gitlab-ci.yml index 2b22162b0c2..e3de301e0a5 100644 --- a/.gitlab/ci/frontend.gitlab-ci.yml +++ b/.gitlab/ci/frontend.gitlab-ci.yml @@ -286,15 +286,24 @@ qa-frontend-node:latest: webpack-dev-server: extends: - .default-retry - - .default-cache - .frontend:rules:default-frontend-jobs stage: test - needs: ["setup-test-env pg11", "compile-assets pull-cache"] + needs: [] variables: WEBPACK_MEMORY_TEST: "true" WEBPACK_VENDOR_DLL: "true" + cache: + key: + files: + - yarn.lock + - config/webpack.vendor.config.js + prefix: "v2" + paths: + - node_modules/ script: - - yarn webpack-vendor + - source scripts/utils.sh + - retry yarn install --frozen-lockfile + - retry yarn webpack-vendor - node --expose-gc node_modules/.bin/webpack-dev-server --config config/webpack.config.js artifacts: name: webpack-dev-server diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 547f33faaa2..8f37a12af75 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -64,10 +64,10 @@ export default { required: false, default: '', }, - singleEmbed: { - type: Boolean, + height: { + type: Number, required: false, - default: false, + default: chartHeight, }, thresholds: { type: Array, @@ -100,7 +100,6 @@ export default { sha: '', }, width: 0, - height: chartHeight, svgs: {}, primaryColor: null, throttledDatazoom: null, diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 7b09c78aae2..2b1791ad3e8 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -31,16 +31,12 @@ import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from ' const events = { timeRangeZoom: 'timerangezoom', + expand: 'expand', }; export default { components: { MonitorEmptyChart, - MonitorSingleStatChart, - MonitorHeatmapChart, - MonitorColumnChart, - MonitorBarChart, - MonitorStackedColumnChart, AlertWidget, GlIcon, GlLoadingIcon, @@ -65,11 +61,6 @@ export default { type: Object, required: true, }, - index: { - type: String, - required: false, - default: '', - }, groupId: { type: String, required: false, @@ -96,6 +87,7 @@ export default { showTitleTooltip: false, zoomedTimeRange: null, allAlerts: {}, + expandBtnAvailable: Boolean(this.$listeners[events.expand]), }; }, computed: { @@ -156,20 +148,55 @@ export default { const data = new Blob([this.csvText], { type: 'text/plain' }); return window.URL.createObjectURL(data); }, - timeChartComponent() { + + /** + * A chart is "basic" if it doesn't support + * the same features as the TimeSeries based components + * such as "annotations". + * + * @returns Vue Component wrapping a basic visualization + */ + basicChartComponent() { + if (this.isPanelType(panelTypes.SINGLE_STAT)) { + return MonitorSingleStatChart; + } + if (this.isPanelType(panelTypes.HEATMAP)) { + return MonitorHeatmapChart; + } + if (this.isPanelType(panelTypes.BAR)) { + return MonitorBarChart; + } + if (this.isPanelType(panelTypes.COLUMN)) { + return MonitorColumnChart; + } + if (this.isPanelType(panelTypes.STACKED_COLUMN)) { + return MonitorStackedColumnChart; + } + if (this.isPanelType(panelTypes.ANOMALY_CHART)) { + return MonitorAnomalyChart; + } + return null; + }, + + /** + * In monitoring, Time Series charts typically support + * a larger feature set like "annotations", "deployment + * data", alert "thresholds" and "datazoom". + * + * This is intentional as Time Series are more frequently + * used. + * + * @returns Vue Component wrapping a time series visualization, + * Area Charts are rendered by default. + */ + timeSeriesChartComponent() { if (this.isPanelType(panelTypes.ANOMALY_CHART)) { return MonitorAnomalyChart; } return MonitorTimeSeriesChart; }, isContextualMenuShown() { - return ( - this.graphDataHasResult && - !this.isPanelType(panelTypes.SINGLE_STAT) && - !this.isPanelType(panelTypes.HEATMAP) && - !this.isPanelType(panelTypes.COLUMN) && - !this.isPanelType(panelTypes.STACKED_COLUMN) - ); + return Boolean(this.graphDataHasResult && !this.basicChartComponent); }, editCustomMetricLink() { return this.graphData?.metrics[0].edit_path; @@ -224,6 +251,9 @@ export default { this.zoomedTimeRange = { start, end }; this.$emit(events.timeRangeZoom, { start, end }); }, + onExpand() { + this.$emit(events.expand); + }, setAlerts(alertPath, alertAttributes) { if (alertAttributes) { this.$set(this.allAlerts, alertPath, alertAttributes); @@ -238,6 +268,7 @@ export default { <template> <div v-gl-resize-observer="onResize" class="prometheus-graph"> <div class="d-flex align-items-center mr-3"> + <slot name="topLeft"></slot> <h5 ref="graphTitle" class="prometheus-graph-title gl-font-size-large font-weight-bold text-truncate append-right-8" @@ -250,7 +281,7 @@ export default { <alert-widget v-if="isContextualMenuShown && alertWidgetAvailable" class="mx-1" - :modal-id="`alert-modal-${index}`" + :modal-id="`alert-modal-${graphData.id}`" :alerts-endpoint="alertsEndpoint" :relevant-queries="graphData.metrics" :alerts-to-manage="getGraphAlerts(graphData.metrics)" @@ -277,6 +308,9 @@ export default { <template slot="button-content"> <gl-icon name="ellipsis_v" class="text-secondary" /> </template> + <gl-dropdown-item v-if="expandBtnAvailable" ref="expandBtn" @click="onExpand"> + {{ s__('Metrics|Expand panel') }} + </gl-dropdown-item> <gl-dropdown-item v-if="editCustomMetricLink" ref="editMetricLink" @@ -312,7 +346,7 @@ export default { </gl-dropdown-item> <gl-dropdown-item v-if="alertWidgetAvailable" - v-gl-modal="`alert-modal-${index}`" + v-gl-modal="`alert-modal-${graphData.id}`" data-qa-selector="alert_widget_menu_item" > {{ __('Alerts') }} @@ -322,38 +356,27 @@ export default { </div> </div> - <monitor-single-stat-chart - v-if="isPanelType($options.panelTypes.SINGLE_STAT) && graphDataHasResult" - :graph-data="graphData" - /> - <monitor-heatmap-chart - v-else-if="isPanelType($options.panelTypes.HEATMAP) && graphDataHasResult" - :graph-data="graphData" - /> - <monitor-bar-chart - v-else-if="isPanelType($options.panelTypes.BAR) && graphDataHasResult" - :graph-data="graphData" - /> - <monitor-column-chart - v-else-if="isPanelType($options.panelTypes.COLUMN) && graphDataHasResult" - :graph-data="graphData" - /> - <monitor-stacked-column-chart - v-else-if="isPanelType($options.panelTypes.STACKED_COLUMN) && graphDataHasResult" + <monitor-empty-chart v-if="!graphDataHasResult" /> + <component + :is="basicChartComponent" + v-else-if="basicChartComponent" :graph-data="graphData" + v-bind="$attrs" + v-on="$listeners" /> <component - :is="timeChartComponent" - v-else-if="graphDataHasResult" - ref="timeChart" + :is="timeSeriesChartComponent" + v-else + ref="timeSeriesChart" :graph-data="graphData" :deployment-data="deploymentData" :annotations="annotations" :project-path="projectPath" :thresholds="getGraphAlertValues(graphData.metrics)" :group-id="groupId" + v-bind="$attrs" + v-on="$listeners" @datazoom="onDatazoom" /> - <monitor-empty-chart v-else v-bind="$attrs" v-on="$listeners" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 9f06d18c46f..a47e5f598f5 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -144,6 +144,7 @@ const mapYAxisToViewModel = ({ * @returns {Object} */ const mapPanelToViewModel = ({ + id = null, title = '', type, x_axis = {}, @@ -162,6 +163,7 @@ const mapPanelToViewModel = ({ const yAxis = mapYAxisToViewModel({ name: y_label, ...y_axis }); // eslint-disable-line babel/camelcase return { + id, title, type, xLabel: xAxis.name, diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/reports/components/issue_status_icon.vue index 62a9338b864..d79e3ddd798 100644 --- a/app/assets/javascripts/reports/components/issue_status_icon.vue +++ b/app/assets/javascripts/reports/components/issue_status_icon.vue @@ -8,7 +8,6 @@ export default { Icon, }, props: { - // failed || success status: { type: String, required: true, @@ -27,7 +26,7 @@ export default { return 'status_success_borderless'; } - return 'status_created_borderless'; + return 'dash'; }, isStatusFailed() { return this.status === STATUS_FAILED; diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index b341cc795a0..b66c7a69b71 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -52,7 +52,7 @@ module ExploreHelper end def public_visibility_restricted? - Gitlab::CurrentSettings.restricted_visibility_levels.include? Gitlab::VisibilityLevel::PUBLIC + Gitlab::CurrentSettings.restricted_visibility_levels&.include? Gitlab::VisibilityLevel::PUBLIC end private diff --git a/app/models/group.rb b/app/models/group.rb index a1e3850d362..be101ff40df 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -31,6 +31,7 @@ class Group < Namespace has_many :members_and_requesters, as: :source, class_name: 'GroupMember' has_many :milestones + has_many :sprints has_many :shared_group_links, foreign_key: :shared_with_group_id, class_name: 'GroupGroupLink' has_many :shared_with_group_links, foreign_key: :shared_group_id, class_name: 'GroupGroupLink' has_many :shared_groups, through: :shared_group_links, source: :shared_group diff --git a/app/models/project.rb b/app/models/project.rb index d9c09fe8ace..d2e8c8e6017 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -208,6 +208,7 @@ class Project < ApplicationRecord has_many :services has_many :events has_many :milestones + has_many :sprints has_many :notes has_many :snippets, class_name: 'ProjectSnippet' has_many :hooks, class_name: 'ProjectHook' diff --git a/app/models/sprint.rb b/app/models/sprint.rb new file mode 100644 index 00000000000..b2c6d9df010 --- /dev/null +++ b/app/models/sprint.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class Sprint < ApplicationRecord + belongs_to :project + belongs_to :group +end diff --git a/app/services/groups/import_export/export_service.rb b/app/services/groups/import_export/export_service.rb index f8715b57d6e..c0de1c7c961 100644 --- a/app/services/groups/import_export/export_service.rb +++ b/app/services/groups/import_export/export_service.rb @@ -56,7 +56,7 @@ module Groups end def tree_exporter - Gitlab::ImportExport::Group::LegacyTreeSaver.new( + tree_exporter_class.new( group: @group, current_user: @current_user, shared: @shared, @@ -64,6 +64,14 @@ module Groups ) end + def tree_exporter_class + if ::Feature.enabled?(:group_import_export_ndjson, @group&.parent) + Gitlab::ImportExport::Group::TreeSaver + else + Gitlab::ImportExport::Group::LegacyTreeSaver + end + end + def file_saver Gitlab::ImportExport::Saver.new(exportable: @group, shared: @shared) end diff --git a/changelogs/unreleased/205570-sprint_initial_migrations.yml b/changelogs/unreleased/205570-sprint_initial_migrations.yml new file mode 100644 index 00000000000..c368cde2673 --- /dev/null +++ b/changelogs/unreleased/205570-sprint_initial_migrations.yml @@ -0,0 +1,5 @@ +--- +title: Create Sprints table and barebones model +merge_request: 30125 +author: +type: added diff --git a/changelogs/unreleased/mg-dedupe-monaco-chunks.yml b/changelogs/unreleased/mg-dedupe-monaco-chunks.yml new file mode 100644 index 00000000000..fb249df3f8c --- /dev/null +++ b/changelogs/unreleased/mg-dedupe-monaco-chunks.yml @@ -0,0 +1,5 @@ +--- +title: Improve cacheability of monaco-editor code +merge_request: 30032 +author: +type: performance diff --git a/changelogs/unreleased/sh-fix-error-500-explore.yml b/changelogs/unreleased/sh-fix-error-500-explore.yml new file mode 100644 index 00000000000..dbf3c804b31 --- /dev/null +++ b/changelogs/unreleased/sh-fix-error-500-explore.yml @@ -0,0 +1,5 @@ +--- +title: Fix 500 error on accessing restricted levels +merge_request: 30313 +author: +type: fixed diff --git a/config/webpack.config.js b/config/webpack.config.js index e220482d769..e2e1139d49e 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -260,6 +260,14 @@ module.exports = { chunks: 'initial', minChunks: autoEntriesCount * 0.9, }), + monaco: { + priority: 15, + name: 'monaco', + chunks: 'initial', + test: /[\\/]node_modules[\\/]monaco-editor[\\/]/, + minChunks: 2, + reuseExistingChunk: true, + }, vendors: { priority: 10, chunks: 'async', diff --git a/db/migrate/20200213224220_add_sprints.rb b/db/migrate/20200213224220_add_sprints.rb new file mode 100644 index 00000000000..8d82d1e261a --- /dev/null +++ b/db/migrate/20200213224220_add_sprints.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class AddSprints < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + create_table :sprints, id: :bigserial do |t| + t.timestamps_with_timezone null: false + t.date :start_date + t.date :due_date + + t.references :project, foreign_key: false, index: false + t.references :group, foreign_key: false, index: true + + t.integer :iid, null: false + t.integer :cached_markdown_version + t.integer :state, limit: 2 + # rubocop:disable Migration/AddLimitToTextColumns + t.text :title, null: false + t.text :title_html + t.text :description + t.text :description_html + # rubocop:enable Migration/AddLimitToTextColumns + + t.index :description, name: "index_sprints_on_description_trigram", opclass: :gin_trgm_ops, using: :gin + t.index :due_date + t.index %w(project_id iid), unique: true + t.index :title + t.index :title, name: "index_sprints_on_title_trigram", opclass: :gin_trgm_ops, using: :gin + + t.index %w(project_id title), unique: true, where: 'project_id IS NOT NULL' + t.index %w(group_id title), unique: true, where: 'group_id IS NOT NULL' + end + end +end diff --git a/db/migrate/20200420172113_add_text_limit_to_sprints_title.rb b/db/migrate/20200420172113_add_text_limit_to_sprints_title.rb new file mode 100644 index 00000000000..17707cc9dd1 --- /dev/null +++ b/db/migrate/20200420172113_add_text_limit_to_sprints_title.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddTextLimitToSprintsTitle < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + CONSTRAINT_NAME = 'sprints_title' + + def up + add_text_limit :sprints, :title, 255, constraint_name: CONSTRAINT_NAME + end + + def down + remove_check_constraint :sprints, CONSTRAINT_NAME + end +end diff --git a/db/migrate/20200420172752_add_sprints_foreign_key_to_projects.rb b/db/migrate/20200420172752_add_sprints_foreign_key_to_projects.rb new file mode 100644 index 00000000000..8d1af8f98c7 --- /dev/null +++ b/db/migrate/20200420172752_add_sprints_foreign_key_to_projects.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddSprintsForeignKeyToProjects < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :sprints, :projects, column: :project_id, on_delete: :cascade + end + + def down + with_lock_retries do # rubocop:disable Migration/WithLockRetriesWithoutDdlTransaction + remove_foreign_key :sprints, column: :project_id + end + end +end diff --git a/db/migrate/20200420172927_add_sprints_foreign_key_to_groups.rb b/db/migrate/20200420172927_add_sprints_foreign_key_to_groups.rb new file mode 100644 index 00000000000..81b9805b874 --- /dev/null +++ b/db/migrate/20200420172927_add_sprints_foreign_key_to_groups.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddSprintsForeignKeyToGroups < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_foreign_key :sprints, :namespaces, column: :group_id, on_delete: :cascade + end + + def down + with_lock_retries do # rubocop:disable Migration/WithLockRetriesWithoutDdlTransaction + remove_foreign_key :sprints, column: :group_id + end + end +end diff --git a/db/structure.sql b/db/structure.sql index b3e605b97cf..982af99b2a0 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -6071,6 +6071,33 @@ CREATE SEQUENCE public.spam_logs_id_seq ALTER SEQUENCE public.spam_logs_id_seq OWNED BY public.spam_logs.id; +CREATE TABLE public.sprints ( + id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + start_date date, + due_date date, + project_id bigint, + group_id bigint, + iid integer NOT NULL, + cached_markdown_version integer, + state smallint, + title text NOT NULL, + title_html text, + description text, + description_html text, + CONSTRAINT sprints_title CHECK ((char_length(title) <= 255)) +); + +CREATE SEQUENCE public.sprints_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.sprints_id_seq OWNED BY public.sprints.id; + CREATE TABLE public.status_page_settings ( project_id bigint NOT NULL, created_at timestamp with time zone NOT NULL, @@ -7589,6 +7616,8 @@ ALTER TABLE ONLY public.software_licenses ALTER COLUMN id SET DEFAULT nextval('p ALTER TABLE ONLY public.spam_logs ALTER COLUMN id SET DEFAULT nextval('public.spam_logs_id_seq'::regclass); +ALTER TABLE ONLY public.sprints ALTER COLUMN id SET DEFAULT nextval('public.sprints_id_seq'::regclass); + ALTER TABLE ONLY public.status_page_settings ALTER COLUMN project_id SET DEFAULT nextval('public.status_page_settings_project_id_seq'::regclass); ALTER TABLE ONLY public.subscriptions ALTER COLUMN id SET DEFAULT nextval('public.subscriptions_id_seq'::regclass); @@ -8515,6 +8544,9 @@ ALTER TABLE ONLY public.software_licenses ALTER TABLE ONLY public.spam_logs ADD CONSTRAINT spam_logs_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public.sprints + ADD CONSTRAINT sprints_pkey PRIMARY KEY (id); + ALTER TABLE ONLY public.status_page_settings ADD CONSTRAINT status_page_settings_pkey PRIMARY KEY (project_id); @@ -10327,6 +10359,22 @@ CREATE INDEX index_software_licenses_on_spdx_identifier ON public.software_licen CREATE UNIQUE INDEX index_software_licenses_on_unique_name ON public.software_licenses USING btree (name); +CREATE INDEX index_sprints_on_description_trigram ON public.sprints USING gin (description public.gin_trgm_ops); + +CREATE INDEX index_sprints_on_due_date ON public.sprints USING btree (due_date); + +CREATE INDEX index_sprints_on_group_id ON public.sprints USING btree (group_id); + +CREATE UNIQUE INDEX index_sprints_on_group_id_and_title ON public.sprints USING btree (group_id, title) WHERE (group_id IS NOT NULL); + +CREATE UNIQUE INDEX index_sprints_on_project_id_and_iid ON public.sprints USING btree (project_id, iid); + +CREATE UNIQUE INDEX index_sprints_on_project_id_and_title ON public.sprints USING btree (project_id, title) WHERE (project_id IS NOT NULL); + +CREATE INDEX index_sprints_on_title ON public.sprints USING btree (title); + +CREATE INDEX index_sprints_on_title_trigram ON public.sprints USING gin (title public.gin_trgm_ops); + CREATE INDEX index_status_page_settings_on_project_id ON public.status_page_settings USING btree (project_id); CREATE INDEX index_subscriptions_on_project_id ON public.subscriptions USING btree (project_id); @@ -10894,6 +10942,9 @@ ALTER TABLE ONLY public.labels ALTER TABLE ONLY public.merge_request_metrics ADD CONSTRAINT fk_7f28d925f3 FOREIGN KEY (merged_by_id) REFERENCES public.users(id) ON DELETE SET NULL; +ALTER TABLE ONLY public.sprints + ADD CONSTRAINT fk_80aa8a1f95 FOREIGN KEY (group_id) REFERENCES public.namespaces(id) ON DELETE CASCADE; + ALTER TABLE ONLY public.import_export_uploads ADD CONSTRAINT fk_83319d9721 FOREIGN KEY (group_id) REFERENCES public.namespaces(id) ON DELETE CASCADE; @@ -11134,6 +11185,9 @@ ALTER TABLE ONLY public.namespaces ALTER TABLE ONLY public.fork_networks ADD CONSTRAINT fk_e7b436b2b5 FOREIGN KEY (root_project_id) REFERENCES public.projects(id) ON DELETE SET NULL; +ALTER TABLE ONLY public.sprints + ADD CONSTRAINT fk_e8206c9686 FOREIGN KEY (project_id) REFERENCES public.projects(id) ON DELETE CASCADE; + ALTER TABLE ONLY public.application_settings ADD CONSTRAINT fk_e8a145f3a7 FOREIGN KEY (instance_administrators_group_id) REFERENCES public.namespaces(id) ON DELETE SET NULL; @@ -13151,6 +13205,7 @@ COPY "schema_migrations" (version) FROM STDIN; 20200213204737 20200213220159 20200213220211 +20200213224220 20200214025454 20200214034836 20200214085940 @@ -13365,6 +13420,9 @@ COPY "schema_migrations" (version) FROM STDIN; 20200416120128 20200416120354 20200417044453 +20200420172113 +20200420172752 +20200420172927 20200421233150 20200423075720 20200423080334 diff --git a/doc/README.md b/doc/README.md index 8d4be768d2d..9ab5f7526c9 100644 --- a/doc/README.md +++ b/doc/README.md @@ -118,6 +118,7 @@ The following documentation relates to the DevOps **Plan** stage: | [Project Issue Board](user/project/issue_board.md) | Display issues on a Scrum or Kanban board. | | [Quick Actions](user/project/quick_actions.md) | Shortcuts for common actions on issues or merge requests, replacing the need to click buttons or use dropdowns in GitLab's UI. | | [Related Issues](user/project/issues/related_issues.md) **(STARTER)** | Create a relationship between issues. | +| [Requirements Management](user/project/requirements/index.md) **(ULTIMATE)** | Check your products against a set of criteria. | | [Roadmap](user/group/roadmap/index.md) **(ULTIMATE)** | Visualize epic timelines. | | [Service Desk](user/project/service_desk.md) **(PREMIUM)** | A simple way to allow people to create issues in your GitLab instance without needing their own user account. | | [Time Tracking](user/project/time_tracking.md) | Track time spent on issues and merge requests. | diff --git a/doc/api/members.md b/doc/api/members.md index e9131e2d4c3..afeda7780d7 100644 --- a/doc/api/members.md +++ b/doc/api/members.md @@ -282,6 +282,78 @@ Example response: } ``` +### Set override flag for a member of a group + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/4875) in GitLab 12.10. + +By default, the access level of LDAP group members is set to the value specified +by LDAP through Group Sync. You can allow access level overrides by calling this endpoint. + +```plaintext +POST /groups/:id/members/:user_id/override +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `user_id` | integer | yes | The user ID of the member | + +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/members/:user_id/override +``` + +Example response: + +```json +{ + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c2525a7f58ae3776070e44c106c48e15?s=80&d=identicon", + "web_url": "http://192.168.1.8:3000/root", + "expires_at": "2012-10-22T14:13:35Z", + "access_level": 40, + "override": true +} +``` + +### Remove override for a member of a group + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/4875) in GitLab 12.10. + +Sets the override flag to false and allows LDAP Group Sync to reset the access +level to the LDAP-prescribed value. + +```plaintext +DELETE /groups/:id/members/:user_id/override +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `user_id` | integer | yes | The user ID of the member | + +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/members/:user_id/override +``` + +Example response: + +```json +{ + "id": 1, + "username": "raymond_smith", + "name": "Raymond Smith", + "state": "active", + "avatar_url": "https://www.gravatar.com/avatar/c2525a7f58ae3776070e44c106c48e15?s=80&d=identicon", + "web_url": "http://192.168.1.8:3000/root", + "expires_at": "2012-10-22T14:13:35Z", + "access_level": 40, + "override": false +} +``` + ## Remove a member from a group or project Removes a user from a group or project. diff --git a/doc/ci/img/metrics_reports.png b/doc/ci/img/metrics_reports.png Binary files differdeleted file mode 100644 index ffd9f6830a2..00000000000 --- a/doc/ci/img/metrics_reports.png +++ /dev/null diff --git a/doc/ci/img/metrics_reports_v13_0.png b/doc/ci/img/metrics_reports_v13_0.png Binary files differnew file mode 100644 index 00000000000..1597031db0b --- /dev/null +++ b/doc/ci/img/metrics_reports_v13_0.png diff --git a/doc/ci/introduction/index.md b/doc/ci/introduction/index.md index b16cde54b93..78317917c82 100644 --- a/doc/ci/introduction/index.md +++ b/doc/ci/introduction/index.md @@ -76,6 +76,9 @@ to apply all the continuous methods (Continuous Integration, Delivery, and Deployment) to your software with no third-party application or integration needed. +<i class="fa fa-youtube-play youtube" aria-hidden="true"></i> +For an overview, see [Introduction to GitLab CI](https://www.youtube.com/watch?v=l5705U8s_nQ&t=397) from a recent GitLab meetup. + ### How GitLab CI/CD works To use GitLab CI/CD, all you need is an application codebase hosted in a @@ -212,7 +215,7 @@ With GitLab CI/CD you can also: To see all CI/CD features, navigate back to the [CI/CD index](../README.md). <i class="fa fa-youtube-play youtube" aria-hidden="true"></i> -Watch the video [GitLab CI Live Demo](https://www.youtube.com/watch?v=pBe4t1CD8Fc) with a deeper overview of GitLab CI/CD. +Watch the video [GitLab CI Live Demo](https://youtu.be/l5705U8s_nQ?t=369) with a deeper overview of GitLab CI/CD. ### Setting up GitLab CI/CD for the first time diff --git a/doc/ci/metrics_reports.md b/doc/ci/metrics_reports.md index 871f8c55e83..d5c76c1f3f9 100644 --- a/doc/ci/metrics_reports.md +++ b/doc/ci/metrics_reports.md @@ -12,7 +12,7 @@ GitLab provides a lot of great reporting tools for [merge requests](../user/proj You can configure your job to use custom Metrics Reports, and GitLab will display a report on the merge request so that it's easier and faster to identify changes without having to check the entire log. - + ## Use cases diff --git a/doc/user/compliance/license_compliance/img/license_compliance_pipeline_tab_v12_3.png b/doc/user/compliance/license_compliance/img/license_compliance_pipeline_tab_v12_3.png Binary files differdeleted file mode 100644 index fd519d63b3e..00000000000 --- a/doc/user/compliance/license_compliance/img/license_compliance_pipeline_tab_v12_3.png +++ /dev/null diff --git a/doc/user/compliance/license_compliance/img/license_compliance_pipeline_tab_v13_0.png b/doc/user/compliance/license_compliance/img/license_compliance_pipeline_tab_v13_0.png Binary files differnew file mode 100644 index 00000000000..5dc46dbf979 --- /dev/null +++ b/doc/user/compliance/license_compliance/img/license_compliance_pipeline_tab_v13_0.png diff --git a/doc/user/compliance/license_compliance/index.md b/doc/user/compliance/license_compliance/index.md index f9b5a1e8d3c..22b0dfb2293 100644 --- a/doc/user/compliance/license_compliance/index.md +++ b/doc/user/compliance/license_compliance/index.md @@ -432,7 +432,7 @@ From your project's left sidebar, navigate to **CI/CD > Pipelines** and click on pipeline ID that has a `license_management` job to see the Licenses tab with the listed licenses (if any). - + <!-- ## Troubleshooting diff --git a/doc/user/project/file_lock.md b/doc/user/project/file_lock.md index d5f35051e9a..b5350515c30 100644 --- a/doc/user/project/file_lock.md +++ b/doc/user/project/file_lock.md @@ -2,9 +2,9 @@ > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/440) in [GitLab Premium](https://about.gitlab.com/pricing/) 8.9. -File Locking helps you avoid merge conflicts and better manage your binary files. -Lock any file or directory, make your changes, and then unlock it so another -member of the team can edit it. +Working with multiple people on the same file can be a risk. Conflicts when merging a non-text file are hard to overcome and will require a lot of manual work to resolve. File Locking helps you avoid these merge conflicts and better manage your binary files. + +With File Locaking, you can lock any file or directory, make your changes, and then unlock it so another member of the team can edit it. ## Overview diff --git a/doc/user/project/issues/index.md b/doc/user/project/issues/index.md index 55e9967a370..bc297d5891d 100644 --- a/doc/user/project/issues/index.md +++ b/doc/user/project/issues/index.md @@ -28,6 +28,11 @@ you can also view all the issues collectively at the group level. See also [Always start a discussion with an issue](https://about.gitlab.com/blog/2016/03/03/start-with-an-issue/). +<i class="fa fa-youtube-play youtube" aria-hidden="true"></i> +To learn how GitLab's Strategic Marketing department uses GitLab issues with [labels](../labels.md) and +[issue boards](../issue_board.md), see the video on +[Managing Commitments with Issues](https://www.youtube.com/watch?v=cuIHNintg1o&t=3). + ## Parts of an issue Issues contain a variety of content and metadata, enabling a large range of flexibility diff --git a/doc/user/project/labels.md b/doc/user/project/labels.md index 618ef9cfc47..f3b59147d5b 100644 --- a/doc/user/project/labels.md +++ b/doc/user/project/labels.md @@ -2,13 +2,18 @@ ## Overview -Labels allow you to categorize epics, issues, and merge requests using descriptive titles like -`bug`, `feature request`, or `docs`, as well as customizable colors. They allow you to quickly -and dynamically filter and manage epics, issues, and merge requests, and are a key -part of [issue boards](issue_board.md). - -You can use labels to help [search](../search/index.md#issues-and-merge-requests) in -lists of issues, merge requests, and epics, as well as [search in issue boards](../search/index.md#issue-boards). +As your count of issues, merge requests, and epics grows in GitLab, it's more and more challenging +to keep track of those items. Especially as your organization grows from just a few people to +hundreds or thousands. This is where labels come in. They help you organize and tag your work +so you can track and find the work items you're interested in. + +Labels are a key part of [issue boards](issue_board.md). With labels you can: + +- Categorize epics, issues, and merge requests using colors and descriptive titles like +`bug`, `feature request`, or `docs`. +- Dynamically filter and manage epics, issues, and merge requests. +- [Search lists of issues, merge requests, and epics](../search/index.md#issues-and-merge-requests), + as well as [issue boards](../search/index.md#issue-boards). ## Project labels and group labels diff --git a/lib/api/members.rb b/lib/api/members.rb index 2e49b4be45c..37d4ca29b68 100644 --- a/lib/api/members.rb +++ b/lib/api/members.rb @@ -160,3 +160,5 @@ module API end end end + +API::Members.prepend_if_ee('EE::API::Members') diff --git a/lib/gitlab/import_export.rb b/lib/gitlab/import_export.rb index e696abcc51c..cb0a24c8864 100644 --- a/lib/gitlab/import_export.rb +++ b/lib/gitlab/import_export.rb @@ -91,6 +91,10 @@ module Gitlab def legacy_group_config_file Rails.root.join('lib/gitlab/import_export/group/legacy_import_export.yml') end + + def group_config_file + Rails.root.join('lib/gitlab/import_export/group/import_export.yml') + end end end diff --git a/lib/gitlab/import_export/group/import_export.yml b/lib/gitlab/import_export/group/import_export.yml new file mode 100644 index 00000000000..e30206dc509 --- /dev/null +++ b/lib/gitlab/import_export/group/import_export.yml @@ -0,0 +1,84 @@ +# Model relationships to be included in the group import/export +# +# This list _must_ only contain relationships that are available to both FOSS and +# Enterprise editions. EE specific relationships must be defined in the `ee` section further +# down below. +tree: + group: + - :milestones + - :badges + - labels: + - :priorities + - boards: + - lists: + - label: + - :priorities + - :board + - members: + - :user + +included_attributes: + user: + - :id + - :email + - :username + author: + - :name + +excluded_attributes: + group: + - :owner_id + - :created_at + - :updated_at + - :runners_token + - :runners_token_encrypted + - :saml_discovery_token + - :visibility_level + - :trial_ends_on + - :shared_runners_minute_limit + - :extra_shared_runners_minutes_limit + epics: + - :state_id + +methods: + labels: + - :type + label: + - :type + badges: + - :type + notes: + - :type + events: + - :action + lists: + - :list_type + epics: + - :state + +preloads: + +# EE specific relationships and settings to include. All of this will be merged +# into the previous structures if EE is used. +ee: + tree: + group: + - epics: + - :parent + - :award_emoji + - events: + - :push_event_payload + - notes: + - :author + - :award_emoji + - events: + - :push_event_payload + - boards: + - :board_assignee + - :milestone + - labels: + - :priorities + - lists: + - milestone: + - events: + - :push_event_payload diff --git a/lib/gitlab/import_export/group/tree_saver.rb b/lib/gitlab/import_export/group/tree_saver.rb new file mode 100644 index 00000000000..d538de33c51 --- /dev/null +++ b/lib/gitlab/import_export/group/tree_saver.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Gitlab + module ImportExport + module Group + class TreeSaver + attr_reader :full_path, :shared + + def initialize(group:, current_user:, shared:, params: {}) + @params = params + @current_user = current_user + @shared = shared + @group = group + @full_path = File.join(@shared.export_path, 'tree') + end + + def save + all_groups = Enumerator.new do |group_ids| + groups.each do |group| + serialize(group) + group_ids << group.id + end + end + + json_writer.write_relation_array('groups', '_all', all_groups) + + true + rescue => e + @shared.error(e) + false + ensure + json_writer&.close + end + + private + + def groups + @groups ||= Gitlab::ObjectHierarchy + .new(::Group.where(id: @group.id)) + .base_and_descendants(with_depth: true) + .order_by(:depth) + end + + def serialize(group) + ImportExport::JSON::StreamingSerializer.new( + group, + group_tree, + json_writer, + exportable_path: "groups/#{group.id}" + ).execute + end + + def group_tree + @group_tree ||= Gitlab::ImportExport::Reader.new( + shared: @shared, + config: group_config + ).group_tree + end + + def group_config + Gitlab::ImportExport::Config.new( + config: Gitlab::ImportExport.group_config_file + ).to_h + end + + def json_writer + @json_writer ||= ImportExport::JSON::NdjsonWriter.new(@full_path) + end + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 610c504bfee..1e55090bc8f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -13057,6 +13057,9 @@ msgid_plural "Metrics|Edit metrics" msgstr[0] "" msgstr[1] "" +msgid "Metrics|Expand panel" +msgstr "" + msgid "Metrics|For grouping similar metrics" msgstr "" diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index c05bf1a547d..7d5a08bc4a1 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; import { setTestTimeout } from 'helpers/timeout'; import { GlLink } from '@gitlab/ui'; import { TEST_HOST } from 'jest/helpers/test_constants'; @@ -11,7 +11,7 @@ import { import { cloneDeep } from 'lodash'; import { shallowWrapperContainsSlotText } from 'helpers/vue_test_utils_helper'; import { createStore } from '~/monitoring/stores'; -import { panelTypes } from '~/monitoring/constants'; +import { panelTypes, chartHeight } from '~/monitoring/constants'; import TimeSeries from '~/monitoring/components/charts/time_series.vue'; import * as types from '~/monitoring/stores/mutation_types'; import { deploymentData, mockProjectDir, annotationsData } from '../../mock_data'; @@ -40,10 +40,10 @@ describe('Time series component', () => { let mockGraphData; let store; - const makeTimeSeriesChart = (graphData, type) => - mount(TimeSeries, { + const createWrapper = (graphData = mockGraphData, mountingMethod = shallowMount) => + mountingMethod(TimeSeries, { propsData: { - graphData: { ...graphData, type }, + graphData, deploymentData: store.state.monitoringDashboard.deploymentData, annotations: store.state.monitoringDashboard.annotations, projectPath: `${TEST_HOST}${mockProjectDir}`, @@ -80,9 +80,9 @@ describe('Time series component', () => { const findChart = () => timeSeriesChart.find({ ref: 'chart' }); - beforeEach(done => { - timeSeriesChart = makeTimeSeriesChart(mockGraphData, 'area-chart'); - timeSeriesChart.vm.$nextTick(done); + beforeEach(() => { + timeSeriesChart = createWrapper(mockGraphData, mount); + return timeSeriesChart.vm.$nextTick(); }); it('allows user to override max value label text using prop', () => { @@ -101,6 +101,21 @@ describe('Time series component', () => { }); }); + it('chart sets a default height', () => { + const wrapper = createWrapper(); + expect(wrapper.props('height')).toBe(chartHeight); + }); + + it('chart has a configurable height', () => { + const mockHeight = 599; + const wrapper = createWrapper(); + + wrapper.setProps({ height: mockHeight }); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.props('height')).toBe(mockHeight); + }); + }); + describe('events', () => { describe('datazoom', () => { let eChartMock; @@ -126,7 +141,7 @@ describe('Time series component', () => { }), }; - timeSeriesChart = makeTimeSeriesChart(mockGraphData); + timeSeriesChart = createWrapper(mockGraphData, mount); timeSeriesChart.vm.$nextTick(() => { findChart().vm.$emit('created', eChartMock); done(); @@ -551,7 +566,10 @@ describe('Time series component', () => { const findChartComponent = () => timeSeriesAreaChart.find(dynamicComponent.component); beforeEach(done => { - timeSeriesAreaChart = makeTimeSeriesChart(mockGraphData, dynamicComponent.chartType); + timeSeriesAreaChart = createWrapper( + { ...mockGraphData, type: dynamicComponent.chartType }, + mount, + ); timeSeriesAreaChart.vm.$nextTick(done); }); @@ -633,7 +651,7 @@ describe('Time series component', () => { Object.assign(metric, { result: metricResultStatus.result }), ); - timeSeriesChart = makeTimeSeriesChart(graphData, 'area-chart'); + timeSeriesChart = createWrapper({ ...graphData, type: 'area-chart' }, mount); timeSeriesChart.vm.$nextTick(done); }); diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index 3c0292e016d..d440c063dd4 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -52,11 +52,11 @@ describe('Dashboard Panel', () => { const exampleText = 'example_text'; const findCopyLink = () => wrapper.find({ ref: 'copyChartLink' }); - const findTimeChart = () => wrapper.find({ ref: 'timeChart' }); + const findTimeChart = () => wrapper.find({ ref: 'timeSeriesChart' }); const findTitle = () => wrapper.find({ ref: 'graphTitle' }); const findContextualMenu = () => wrapper.find({ ref: 'contextualMenu' }); - const createWrapper = props => { + const createWrapper = (props, options = {}) => { wrapper = shallowMount(DashboardPanel, { propsData: { graphData, @@ -64,6 +64,7 @@ describe('Dashboard Panel', () => { }, store, mocks, + ...options, }); }; @@ -80,6 +81,22 @@ describe('Dashboard Panel', () => { axiosMock.reset(); }); + describe('Renders slots', () => { + it('renders "topLeft" slot', () => { + createWrapper( + {}, + { + slots: { + topLeft: `<div class="top-left-content">OK</div>`, + }, + }, + ); + + expect(wrapper.find('.top-left-content').exists()).toBe(true); + expect(wrapper.find('.top-left-content').text()).toBe('OK'); + }); + }); + describe('When no graphData is available', () => { beforeEach(() => { createWrapper({ @@ -111,7 +128,7 @@ describe('Dashboard Panel', () => { }); }); - describe('when graph data is available', () => { + describe('When graphData is available', () => { beforeEach(() => { createWrapper(); }); @@ -182,10 +199,13 @@ describe('Dashboard Panel', () => { ${singleStatMetricsResult} | ${MonitorSingleStatChart} ${graphDataPrometheusQueryRangeMultiTrack} | ${MonitorHeatmapChart} ${barMockData} | ${MonitorBarChart} - `('type $data.type renders the expected component', ({ data, component }) => { - createWrapper({ graphData: data }); + `('wrapps a $data.type component binding attributes', ({ data, component }) => { + const attrs = { attr1: 'attr1Value', attr2: 'attr2Value' }; + createWrapper({ graphData: data }, { attrs }); + expect(wrapper.find(component).exists()).toBe(true); expect(wrapper.find(component).isVueInstance()).toBe(true); + expect(wrapper.find(component).attributes()).toMatchObject(attrs); }); }); }); @@ -436,6 +456,32 @@ describe('Dashboard Panel', () => { }); }); + describe('Expand to full screen', () => { + const findExpandBtn = () => wrapper.find({ ref: 'expandBtn' }); + + describe('when there is no @expand listener', () => { + it('does not show `View full screen` option', () => { + createWrapper(); + expect(findExpandBtn().exists()).toBe(false); + }); + }); + + describe('when there is an @expand listener', () => { + beforeEach(() => { + createWrapper({}, { listeners: { expand: () => {} } }); + }); + + it('shows the `expand` option', () => { + expect(findExpandBtn().exists()).toBe(true); + }); + + it('emits the `expand` event', () => { + findExpandBtn().vm.$emit('click'); + expect(wrapper.emitted('expand')).toHaveLength(1); + }); + }); + }); + describe('panel alerts', () => { const setMetricsSavedToDb = val => monitoringDashboard.getters.metricsSavedToDb.mockReturnValue(val); diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js index 7ee2a16b4bd..fe5754e1216 100644 --- a/spec/frontend/monitoring/store/utils_spec.js +++ b/spec/frontend/monitoring/store/utils_spec.js @@ -27,6 +27,7 @@ describe('mapToDashboardViewModel', () => { group: 'Group 1', panels: [ { + id: 'ID_ABC', title: 'Title A', xLabel: '', xAxis: { @@ -49,6 +50,7 @@ describe('mapToDashboardViewModel', () => { key: 'group-1-0', panels: [ { + id: 'ID_ABC', title: 'Title A', type: 'chart-type', xLabel: '', @@ -127,11 +129,13 @@ describe('mapToDashboardViewModel', () => { it('panel with x_label', () => { setupWithPanel({ + id: 'ID_123', title: panelTitle, x_label: 'x label', }); expect(getMappedPanel()).toEqual({ + id: 'ID_123', title: panelTitle, xLabel: 'x label', xAxis: { @@ -149,10 +153,12 @@ describe('mapToDashboardViewModel', () => { it('group y_axis defaults', () => { setupWithPanel({ + id: 'ID_456', title: panelTitle, }); expect(getMappedPanel()).toEqual({ + id: 'ID_456', title: panelTitle, xLabel: '', y_label: '', diff --git a/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap b/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap new file mode 100644 index 00000000000..70e1ff01323 --- /dev/null +++ b/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`IssueStatusIcon renders "failed" state correctly 1`] = ` +<div + class="report-block-list-icon failed" +> + <icon-stub + data-qa-selector="status_failed_icon" + name="status_failed_borderless" + size="24" + /> +</div> +`; + +exports[`IssueStatusIcon renders "neutral" state correctly 1`] = ` +<div + class="report-block-list-icon neutral" +> + <icon-stub + data-qa-selector="status_neutral_icon" + name="dash" + size="24" + /> +</div> +`; + +exports[`IssueStatusIcon renders "success" state correctly 1`] = ` +<div + class="report-block-list-icon success" +> + <icon-stub + data-qa-selector="status_success_icon" + name="status_success_borderless" + size="24" + /> +</div> +`; diff --git a/spec/frontend/reports/components/issue_status_icon_spec.js b/spec/frontend/reports/components/issue_status_icon_spec.js new file mode 100644 index 00000000000..3a55ff0a9e3 --- /dev/null +++ b/spec/frontend/reports/components/issue_status_icon_spec.js @@ -0,0 +1,29 @@ +import { shallowMount } from '@vue/test-utils'; +import ReportItem from '~/reports/components/issue_status_icon.vue'; +import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants'; + +describe('IssueStatusIcon', () => { + let wrapper; + + const createComponent = ({ status }) => { + wrapper = shallowMount(ReportItem, { + propsData: { + status, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it.each([STATUS_SUCCESS, STATUS_NEUTRAL, STATUS_FAILED])( + 'renders "%s" state correctly', + status => { + createComponent({ status }); + + expect(wrapper.element).toMatchSnapshot(); + }, + ); +}); diff --git a/spec/helpers/explore_helper_spec.rb b/spec/helpers/explore_helper_spec.rb index 5208d3bd656..f8240dd3a4c 100644 --- a/spec/helpers/explore_helper_spec.rb +++ b/spec/helpers/explore_helper_spec.rb @@ -17,4 +17,25 @@ describe ExploreHelper do expect(helper.explore_nav_links).to contain_exactly(*menu_items) end end + + describe '#public_visibility_restricted?' do + using RSpec::Parameterized::TableSyntax + + where(:visibility_levels, :expected_status) do + nil | nil + [Gitlab::VisibilityLevel::PRIVATE] | false + [Gitlab::VisibilityLevel::PRIVATE, Gitlab::VisibilityLevel::INTERNAL] | false + [Gitlab::VisibilityLevel::PUBLIC] | true + end + + with_them do + before do + stub_application_setting(restricted_visibility_levels: visibility_levels) + end + + it 'returns the expected status' do + expect(helper.public_visibility_restricted?).to eq(expected_status) + end + end + end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 9881bfd3229..10a424c0c11 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -345,6 +345,7 @@ project: - labels - events - milestones +- sprints - notes - snippets - hooks diff --git a/spec/lib/gitlab/import_export/group/tree_saver_spec.rb b/spec/lib/gitlab/import_export/group/tree_saver_spec.rb new file mode 100644 index 00000000000..06e8484a3cb --- /dev/null +++ b/spec/lib/gitlab/import_export/group/tree_saver_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::ImportExport::Group::TreeSaver do + describe 'saves the group tree into a json object' do + let_it_be(:user) { create(:user) } + let_it_be(:group) { setup_groups } + + let(:shared) { Gitlab::ImportExport::Shared.new(group) } + let(:export_path) { "#{Dir.tmpdir}/group_tree_saver_spec" } + + subject(:group_tree_saver) { described_class.new(group: group, current_user: user, shared: shared) } + + before_all do + group.add_maintainer(user) + end + + before do + allow_next_instance_of(Gitlab::ImportExport) do |import_export| + allow(import_export).to receive(:storage_path).and_return(export_path) + end + end + + after do + FileUtils.rm_rf(export_path) + end + + it 'saves the group successfully' do + expect(group_tree_saver.save).to be true + end + + it 'fails to export a group' do + allow_next_instance_of(Gitlab::ImportExport::JSON::NdjsonWriter) do |ndjson_writer| + allow(ndjson_writer).to receive(:write_relation_array).and_raise(RuntimeError, 'exception') + end + + expect(shared).to receive(:error).with(RuntimeError).and_call_original + + expect(group_tree_saver.save).to be false + end + + context 'exported files' do + before do + group_tree_saver.save + end + + it 'has one group per line' do + groups_catalog = + File.readlines(exported_path_for('_all.ndjson')) + .map { |line| Integer(line) } + + expect(groups_catalog.size).to eq(3) + expect(groups_catalog).to eq([ + group.id, + group.descendants.first.id, + group.descendants.first.descendants.first.id + ]) + end + + it 'has a file per group' do + group.self_and_descendants.pluck(:id).each do |id| + group_attributes_file = exported_path_for("#{id}.json") + + expect(File.exist?(group_attributes_file)).to be(true) + end + end + + context 'group attributes file' do + let(:group_attributes_file) { exported_path_for("#{group.id}.json") } + let(:group_attributes) { ::JSON.parse(File.read(group_attributes_file)) } + + it 'has a file for each group with its attributes' do + expect(group_attributes['description']).to eq(group.description) + expect(group_attributes['parent_id']).to eq(group.parent_id) + end + + shared_examples 'excluded attributes' do + excluded_attributes = %w[ + owner_id + created_at + updated_at + runners_token + runners_token_encrypted + saml_discovery_token + ] + + excluded_attributes.each do |excluded_attribute| + it 'does not contain excluded attribute' do + expect(group_attributes).not_to include(excluded_attribute => group.public_send(excluded_attribute)) + end + end + end + + include_examples 'excluded attributes' + end + + it 'has a file for each group association' do + group.self_and_descendants do |g| + %w[ + badges + boards + epics + labels + members + milestones + ].each do |association| + path = exported_path_for("#{g.id}", "#{association}.ndjson") + expect(File.exist?(path)).to eq(true), "#{path} does not exist" + end + end + end + end + end + + def exported_path_for(*file) + File.join(group_tree_saver.full_path, 'groups', *file) + end + + def setup_groups + root = setup_group + subgroup = setup_group(parent: root) + setup_group(parent: subgroup) + + root + end + + def setup_group(parent: nil) + group = create(:group, description: 'description', parent: parent) + create(:milestone, group: group) + create(:group_badge, group: group) + group_label = create(:group_label, group: group) + board = create(:board, group: group, milestone_id: Milestone::Upcoming.id) + create(:list, board: board, label: group_label) + create(:group_badge, group: group) + create(:label_priority, label: group_label, priority: 1) + + group + end +end diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb index 8dabe5a756b..50b045c6aad 100644 --- a/spec/lib/gitlab/path_regex_spec.rb +++ b/spec/lib/gitlab/path_regex_spec.rb @@ -170,6 +170,11 @@ describe Gitlab::PathRegex do expect(described_class::TOP_LEVEL_ROUTES) .to contain_exactly(*top_level_words), failure_block end + + # We ban new items in this list, see https://gitlab.com/gitlab-org/gitlab/-/issues/215362 + it 'does not allow expansion' do + expect(described_class::TOP_LEVEL_ROUTES.size).to eq(41) + end end describe 'GROUP_ROUTES' do @@ -184,6 +189,11 @@ describe Gitlab::PathRegex do expect(described_class::GROUP_ROUTES) .to contain_exactly(*paths_after_group_id), failure_block end + + # We ban new items in this list, see https://gitlab.com/gitlab-org/gitlab/-/issues/215362 + it 'does not allow expansion' do + expect(described_class::GROUP_ROUTES.size).to eq(1) + end end describe 'PROJECT_WILDCARD_ROUTES' do @@ -195,6 +205,11 @@ describe Gitlab::PathRegex do end end end + + # We ban new items in this list, see https://gitlab.com/gitlab-org/gitlab/-/issues/215362 + it 'does not allow expansion' do + expect(described_class::PROJECT_WILDCARD_ROUTES.size).to eq(21) + end end describe '.root_namespace_route_regex' do diff --git a/spec/services/groups/import_export/export_service_spec.rb b/spec/services/groups/import_export/export_service_spec.rb index 56c7121cc34..f77b5a2e5b9 100644 --- a/spec/services/groups/import_export/export_service_spec.rb +++ b/spec/services/groups/import_export/export_service_spec.rb @@ -11,7 +11,7 @@ describe Groups::ImportExport::ExportService do let(:export_service) { described_class.new(group: group, user: user) } it 'enqueues an export job' do - expect(GroupExportWorker).to receive(:perform_async).with(user.id, group.id, {}) + allow(GroupExportWorker).to receive(:perform_async).with(user.id, group.id, {}) export_service.async_execute end @@ -49,7 +49,17 @@ describe Groups::ImportExport::ExportService do FileUtils.rm_rf(archive_path) end - it 'saves the models' do + it 'saves the models using ndjson tree saver' do + stub_feature_flags(group_import_export_ndjson: true) + + expect(Gitlab::ImportExport::Group::TreeSaver).to receive(:new).and_call_original + + service.execute + end + + it 'saves the models using legacy tree saver' do + stub_feature_flags(group_import_export_ndjson: false) + expect(Gitlab::ImportExport::Group::LegacyTreeSaver).to receive(:new).and_call_original service.execute diff --git a/spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb b/spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb index 995cb66a849..76339837351 100644 --- a/spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/blob_replicator_strategy_shared_examples.rb @@ -76,7 +76,7 @@ RSpec.shared_examples 'a blob replicator' do expect(service).to receive(:execute) expect(::Geo::BlobDownloadService).to receive(:new).with(replicator: replicator).and_return(service) - replicator.consume_created_event + replicator.consume_event_created end end |