diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml index 40b2f3f41d4..45037019940 100644 --- a/.gitlab/ci/rules.gitlab-ci.yml +++ b/.gitlab/ci/rules.gitlab-ci.yml @@ -2236,6 +2236,7 @@ when: never - <<: *if-merge-request-labels-run-all-rspec - <<: *if-merge-request-labels-record-queries + - <<: *if-default-branch-refs .rails:rules:default-branch-schedule-nightly--code-backstage-default-rules: rules: diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index ad03724f89d..0429815f10b 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -7ea51fec80a59e85b8ce423ed09a65eb54b568a8 +c0e688008f860bd70647b3a30c1410f75744b6d5 diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue index 9394bac4ee8..6e200f987bf 100644 --- a/app/assets/javascripts/boards/components/board_content_sidebar.vue +++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue @@ -6,7 +6,7 @@ import SidebarDropdownWidget from 'ee_else_ce/sidebar/components/sidebar_dropdow import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; import setActiveBoardItemMutation from 'ee_else_ce/boards/graphql/client/set_active_board_item.mutation.graphql'; import { __, s__, sprintf } from '~/locale'; -import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; +import SidebarTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; import BoardSidebarTitle from '~/boards/components/sidebar/board_sidebar_title.vue'; import { INCIDENT } from '~/boards/constants'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; @@ -27,7 +27,7 @@ export default { SidebarAssigneesWidget, SidebarDateWidget, SidebarConfidentialityWidget, - BoardSidebarTimeTracker, + SidebarTimeTracker, SidebarLabelsWidget, SidebarSubscriptionsWidget, SidebarDropdownWidget, @@ -75,6 +75,9 @@ export default { isApolloBoard: { default: false, }, + timeTrackingLimitToHours: { + default: false, + }, }, inheritAttrs: false, apollo: { @@ -257,7 +260,15 @@ export default { data-testid="iteration-edit" /> - + -import { mapGetters } from 'vuex'; -import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; - -export default { - components: { - IssuableTimeTracker, - }, - inject: ['timeTrackingLimitToHours', 'canUpdate'], - computed: { - ...mapGetters(['activeBoardItem']), - initialTimeTracking() { - const { - timeEstimate, - totalTimeSpent, - humanTimeEstimate, - humanTotalTimeSpent, - } = this.activeBoardItem; - return { - timeEstimate, - totalTimeSpent, - humanTimeEstimate, - humanTotalTimeSpent, - }; - }, - }, -}; - - - diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 6dbf12054cf..4e5e07c57e4 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -136,9 +136,9 @@ export default class Notes { // Reopen and close actions for Issue/MR combined with note form submit this.$wrapperEl.on( 'click', - // this oddly written selector needs to match the old style (input with class) as + // this oddly written selector needs to match the old style (button with class) as // well as the new DOM styling from the Vue-based note form - 'input.js-comment-submit-button, .js-comment-submit-button > button:first-child', + 'button.js-comment-submit-button, .js-comment-submit-button > button:first-child', this.postComment, ); this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment); diff --git a/app/assets/javascripts/sidebar/components/time_tracking/constants.js b/app/assets/javascripts/sidebar/components/time_tracking/constants.js index 56e986e3b27..ddfbf5ab2a6 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/constants.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/constants.js @@ -1 +1,2 @@ export const CREATE_TIMELOG_MODAL_ID = 'create-timelog-modal'; +export const SET_TIME_ESTIMATE_MODAL_ID = 'set-time-estimate-modal'; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/set_time_estimate_form.vue b/app/assets/javascripts/sidebar/components/time_tracking/set_time_estimate_form.vue new file mode 100644 index 00000000000..44c5896d658 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/time_tracking/set_time_estimate_form.vue @@ -0,0 +1,215 @@ + + + diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue index 06adc048942..54f10cac075 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue @@ -35,6 +35,11 @@ export default { required: false, default: true, }, + canSetTimeEstimate: { + type: Boolean, + required: false, + default: false, + }, }, mounted() { this.listenForQuickActions(); @@ -73,6 +78,7 @@ export default { :issuable-iid="issuableIid" :limit-to-hours="limitToHours" :can-add-time-entries="canAddTimeEntries" + :can-set-time-estimate="canSetTimeEstimate" /> diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index f6968558122..1d427a871e1 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -18,8 +18,9 @@ import TimeTrackingCollapsedState from './collapsed_state.vue'; import TimeTrackingComparisonPane from './comparison_pane.vue'; import TimeTrackingReport from './report.vue'; import TimeTrackingSpentOnlyPane from './spent_only_pane.vue'; -import { CREATE_TIMELOG_MODAL_ID } from './constants'; +import { CREATE_TIMELOG_MODAL_ID, SET_TIME_ESTIMATE_MODAL_ID } from './constants'; import CreateTimelogForm from './create_timelog_form.vue'; +import SetTimeEstimateForm from './set_time_estimate_form.vue'; export default { name: 'IssuableTimeTracker', @@ -38,6 +39,7 @@ export default { TimeTrackingComparisonPane, TimeTrackingReport, CreateTimelogForm, + SetTimeEstimateForm, }, directives: { GlModal: GlModalDirective, @@ -94,6 +96,11 @@ export default { required: false, default: true, }, + canSetTimeEstimate: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -181,6 +188,11 @@ export default { timeTrackingIconName() { return this.showHelpState ? 'close' : 'question-o'; }, + timeEstimateTooltip() { + return this.hasTimeEstimate + ? s__('TimeTracking|Edit estimate') + : s__('TimeTracking|Set estimate'); + }, }, watch: { /** @@ -203,6 +215,7 @@ export default { this.$root.$emit(BV_SHOW_MODAL, CREATE_TIMELOG_MODAL_ID); }, }, + setTimeEstimateModalId: SET_TIME_ESTIMATE_MODAL_ID, }; @@ -223,18 +236,31 @@ export default { > {{ __('Time tracking') }} - - - +
+ + + + + + +
@@ -255,10 +281,11 @@ export default { :time-estimate-human-readable="humanTimeEstimate" :limit-to-hours="limitToHours" /> - +
+
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js index b0060e4c28d..cb6d503d6ef 100644 --- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js @@ -36,6 +36,7 @@ export default class SidebarMilestone { humanTotalTimeSpent: humanTimeSpent, }, canAddTimeEntries: false, + canSetTimeEstimate: false, }, }), }); diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 8f6b855ecd6..47563ae1dc3 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -545,6 +545,7 @@ function mountSidebarTimeTracking() { issuableType, timeTrackingLimitToHours, canCreateTimelogs, + editable, } = getSidebarOptions(); if (!el) { @@ -564,6 +565,7 @@ function mountSidebarTimeTracking() { issuableIid: iid.toString(), limitToHours: timeTrackingLimitToHours, canAddTimeEntries: canCreateTimelogs, + canSetTimeEstimate: parseBoolean(editable), }, }), }); diff --git a/app/assets/javascripts/sidebar/queries/issue_set_time_estimate.mutation.graphql b/app/assets/javascripts/sidebar/queries/issue_set_time_estimate.mutation.graphql new file mode 100644 index 00000000000..3e3ebb3869e --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/issue_set_time_estimate.mutation.graphql @@ -0,0 +1,10 @@ +mutation issueSetTimeEstimate($input: UpdateIssueInput!) { + issuableSetTimeEstimate: updateIssue(input: $input) { + errors + issuable: issue { + id + humanTimeEstimate + timeEstimate + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/merge_request_set_time_estimate.mutation.graphql b/app/assets/javascripts/sidebar/queries/merge_request_set_time_estimate.mutation.graphql new file mode 100644 index 00000000000..398b3b1c520 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/merge_request_set_time_estimate.mutation.graphql @@ -0,0 +1,10 @@ +mutation mergeRequestSetTimeEstimate($input: MergeRequestUpdateInput!) { + issuableSetTimeEstimate: mergeRequestUpdate(input: $input) { + errors + issuable: mergeRequest { + id + humanTimeEstimate + timeEstimate + } + } +} diff --git a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue index 37aedc4ff09..31dd49ca415 100644 --- a/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue +++ b/app/assets/javascripts/vue_shared/issuable/list/components/issuable_item.vue @@ -7,8 +7,10 @@ import { isScopedLabel } from '~/lib/utils/common_utils'; import { isExternal, setUrlFragment } from '~/lib/utils/url_utility'; import { __, n__, sprintf } from '~/locale'; import IssuableAssignees from '~/issuable/components/issue_assignees.vue'; -import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; +import { STATE_CLOSED } from '~/work_items/constants'; +import { isAssigneesWidget, isLabelsWidget } from '~/work_items/utils'; export default { components: { @@ -90,26 +92,41 @@ export default { reference() { return this.issuable.reference || `${this.issuableSymbol}${this.issuable.iid}`; }, + type() { + return this.issuable.type || this.issuable.workItemType?.name.toUpperCase(); + }, labels() { - return this.issuable.labels?.nodes || this.issuable.labels || []; + return ( + this.issuable.labels?.nodes || + this.issuable.labels || + this.issuable.widgets?.find(isLabelsWidget)?.labels.nodes || + [] + ); }, labelIdsString() { return JSON.stringify(this.labels.map((label) => getIdFromGraphQLId(label.id))); }, assignees() { - return this.issuable.assignees?.nodes || this.issuable.assignees || []; + return ( + this.issuable.assignees?.nodes || + this.issuable.assignees || + this.issuable.widgets?.find(isAssigneesWidget)?.assignees.nodes || + [] + ); }, createdAt() { return this.timeFormatted(this.issuable.createdAt); }, + isClosed() { + return this.issuable.state === STATUS_CLOSED || this.issuable.state === STATE_CLOSED; + }, timestamp() { - if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) { - return this.issuable.closedAt; - } - return this.issuable.updatedAt; + return this.isClosed && this.issuable.closedAt + ? this.issuable.closedAt + : this.issuable.updatedAt; }, formattedTimestamp() { - if (this.issuable.state === STATUS_CLOSED && this.issuable.closedAt) { + if (this.isClosed && this.issuable.closedAt) { return sprintf(__('closed %{timeago}'), { timeago: this.timeFormatted(this.issuable.closedAt), }); @@ -167,7 +184,10 @@ export default { return Boolean(this.$slots[slotName]); }, scopedLabel(label) { - return this.hasScopedLabelsFeature && isScopedLabel(label); + const allowsScopedLabels = + this.hasScopedLabelsFeature || + this.issuable.widgets?.find(isLabelsWidget)?.allowsScopedLabels; + return allowsScopedLabels && isScopedLabel(label); }, labelTitle(label) { return label.title || label.name; @@ -213,7 +233,7 @@ export default { :checked="checked" :data-id="issuableId" :data-iid="issuableIid" - :data-type="issuable.type" + :data-type="type" @input="$emit('checked-input', $event)" > {{ issuable.title }} @@ -222,7 +242,7 @@ export default {
widget.type === WIDGET_TYPE_ASSIGNEES; - const assigneesWidgetIndex = this.workItem.widgets.findIndex(isAssigneesWidget); const editedWorkItemWidgets = [...this.workItem.widgets]; diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue index 5bfb65fe91c..1405a12a101 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -9,13 +9,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; - -import { - i18n, - I18N_WORK_ITEM_ERROR_FETCHING_LABELS, - TRACKING_CATEGORY_SHOW, - WIDGET_TYPE_LABELS, -} from '../constants'; +import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS, TRACKING_CATEGORY_SHOW } from '../constants'; +import { isLabelsWidget } from '../utils'; function isTokenSelectorElement(el) { return ( @@ -127,7 +122,7 @@ export default { return this.$apollo.queries.searchLabels.loading; }, labelsWidget() { - return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); + return this.workItem?.widgets?.find(isLabelsWidget); }, labels() { return this.labelsWidget?.labels?.nodes || []; diff --git a/app/assets/javascripts/work_items/list/components/work_items_list_app.vue b/app/assets/javascripts/work_items/list/components/work_items_list_app.vue index 4180d484357..fe7cb719bbb 100644 --- a/app/assets/javascripts/work_items/list/components/work_items_list_app.vue +++ b/app/assets/javascripts/work_items/list/components/work_items_list_app.vue @@ -1,8 +1,11 @@ diff --git a/app/assets/javascripts/work_items/list/index.js b/app/assets/javascripts/work_items/list/index.js index 5b701893471..5cd38600779 100644 --- a/app/assets/javascripts/work_items/list/index.js +++ b/app/assets/javascripts/work_items/list/index.js @@ -1,5 +1,7 @@ import Vue from 'vue'; -import WorkItemsListApp from '~/work_items/list/components/work_items_list_app.vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import WorkItemsListApp from './components/work_items_list_app.vue'; export const mountWorkItemsListApp = () => { const el = document.querySelector('.js-work-items-list-root'); @@ -8,9 +10,17 @@ export const mountWorkItemsListApp = () => { return null; } + Vue.use(VueApollo); + return new Vue({ el, name: 'WorkItemsListRoot', + apolloProvider: new VueApollo({ + defaultClient: createDefaultClient(), + }), + provide: { + fullPath: el.dataset.fullPath, + }, render: (createComponent) => createComponent(WorkItemsListApp), }); }; diff --git a/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql b/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql new file mode 100644 index 00000000000..7ada2cf12dd --- /dev/null +++ b/app/assets/javascripts/work_items/list/queries/get_work_items.query.graphql @@ -0,0 +1,56 @@ +query getWorkItems($fullPath: ID!) { + group(fullPath: $fullPath) { + id + workItems { + nodes { + id + author { + id + avatarUrl + name + username + webUrl + } + closedAt + confidential + createdAt + iid + reference(full: true) + state + title + updatedAt + webUrl + widgets { + ... on WorkItemWidgetAssignees { + assignees { + nodes { + id + avatarUrl + name + username + webUrl + } + } + type + } + ... on WorkItemWidgetLabels { + allowsScopedLabels + labels { + nodes { + id + color + description + title + } + } + type + } + } + workItemType { + id + name + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index 81dbe56b2ea..5a882977bc2 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -1,4 +1,8 @@ -import { WIDGET_TYPE_HIERARCHY } from '~/work_items/constants'; +import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_HIERARCHY, WIDGET_TYPE_LABELS } from './constants'; + +export const isAssigneesWidget = (widget) => widget.type === WIDGET_TYPE_ASSIGNEES; + +export const isLabelsWidget = (widget) => widget.type === WIDGET_TYPE_LABELS; export const findHierarchyWidgets = (widgets) => widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); diff --git a/app/models/concerns/each_batch.rb b/app/models/concerns/each_batch.rb index 79fb81e7820..945d286a2fd 100644 --- a/app/models/concerns/each_batch.rb +++ b/app/models/concerns/each_batch.rb @@ -219,6 +219,7 @@ module EachBatch new_count, last_value = unscoped .from(inner_query) + .unscope(where: :type) .order(count: :desc) .limit(1) .pick(:count, column) diff --git a/app/models/namespace/detail.rb b/app/models/namespace/detail.rb index 2660d11171e..6ac325dfbf7 100644 --- a/app/models/namespace/detail.rb +++ b/app/models/namespace/detail.rb @@ -4,6 +4,8 @@ class Namespace::Detail < ApplicationRecord include IgnorableColumns ignore_column :free_user_cap_over_limt_notified_at, remove_with: '15.7', remove_after: '2022-11-22' + ignore_column :dashboard_notification_at, remove_with: '16.5', remove_after: '2023-08-22' + ignore_column :dashboard_enforcement_at, remove_with: '16.5', remove_after: '2023-08-22' belongs_to :namespace, inverse_of: :namespace_details validates :namespace, presence: true diff --git a/app/views/groups/work_items/index.html.haml b/app/views/groups/work_items/index.html.haml index 1d508289b21..2e3d3dda941 100644 --- a/app/views/groups/work_items/index.html.haml +++ b/app/views/groups/work_items/index.html.haml @@ -1,3 +1,4 @@ - page_title s_('WorkItem|Work items') +- add_page_specific_style 'page_bundles/issuable_list' -.js-work-items-list-root +.js-work-items-list-root{ data: { full_path: @group.full_path } } diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml index 3e880a36e29..bbcd072c762 100644 --- a/app/views/shared/notes/_comment_button.html.haml +++ b/app/views/shared/notes/_comment_button.html.haml @@ -1,4 +1,5 @@ - noteable_name = @note.noteable.human_class_name .js-comment-type-dropdown.float-left.gl-sm-mr-3{ data: { noteable_name: noteable_name } } - %input.btn.gl-button.btn-confirm.js-comment-button.js-comment-submit-button{ type: 'submit', value: _('Comment'), data: { qa_selector: 'comment_button' } } + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: 'js-comment-button js-comment-submit-button', value: _('Comment'), data: { qa_selector: 'comment_button' }}) do + = _('Comment') diff --git a/db/migrate/20230807202427_add_label_lock_on_merge_redux.rb b/db/migrate/20230807202427_add_label_lock_on_merge_redux.rb new file mode 100644 index 00000000000..7c9975a0a71 --- /dev/null +++ b/db/migrate/20230807202427_add_label_lock_on_merge_redux.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/128570 +class AddLabelLockOnMergeRedux < Gitlab::Database::Migration[2.1] + def change + add_column :labels, :lock_on_merge, :boolean, default: false, null: false + end +end diff --git a/db/schema_migrations/20230807202427 b/db/schema_migrations/20230807202427 new file mode 100644 index 00000000000..7c3979533e8 --- /dev/null +++ b/db/schema_migrations/20230807202427 @@ -0,0 +1 @@ +479182c1a96d0c827e9de927f529ebaa447fcd3811619d53da4cd52b3462ea21 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 33621bff28f..6ecb967d91e 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -17859,7 +17859,8 @@ CREATE TABLE labels ( description_html text, type character varying, group_id integer, - cached_markdown_version integer + cached_markdown_version integer, + lock_on_merge boolean DEFAULT false NOT NULL ); CREATE SEQUENCE labels_id_seq diff --git a/doc/ci/testing/test_coverage_visualization.md b/doc/ci/testing/test_coverage_visualization.md index 613cefe1a0d..dc59e25fa80 100644 --- a/doc/ci/testing/test_coverage_visualization.md +++ b/doc/ci/testing/test_coverage_visualization.md @@ -19,7 +19,7 @@ MR is merged. ## How test coverage visualization works -Collecting the coverage information is done via GitLab CI/CD's +Collecting the coverage information is done by using the GitLab CI/CD [artifacts reports feature](../yaml/index.md#artifactsreports). You can specify one or more coverage reports to collect, including wildcard paths. GitLab then takes the coverage information in all the files and combines it @@ -41,8 +41,7 @@ Other coverage analysis frameworks support the format out of the box, for exampl - [Coverage.py](https://coverage.readthedocs.io/en/coverage-5.0.4/cmd.html#xml-reporting) (Python) - [PHPUnit](https://github.com/sebastianbergmann/phpunit-documentation-english/blob/master/src/textui.rst#command-line-options) (PHP) -Once configured, if you create a merge request that triggers a pipeline which collects -coverage reports, the coverage is shown in the diff view. This includes reports +After configuration, if your merge request triggers a pipeline that collects coverage reports, the coverage information is displayed in the diff view. This includes reports from any job in any stage in the pipeline. The coverage displays for each line: - `covered` (green): lines which have been checked at least once by tests diff --git a/doc/user/project/repository/code_suggestions.md b/doc/user/project/repository/code_suggestions.md index b4d887de08f..a757b93bd95 100644 --- a/doc/user/project/repository/code_suggestions.md +++ b/doc/user/project/repository/code_suggestions.md @@ -58,7 +58,7 @@ The best results from Code Suggestions are expected [for languages the Google Ve - Swift - TypeScript -## Supported code infrastructure interfaces +### Supported code infrastructure interfaces Code Suggestions includes [Google Vertex AI Codey APIs](https://cloud.google.com/vertex-ai/docs/generative-ai/code/code-models-overview#supported_code_infrastructure_interfaces) support for the following infrastructure as code interfaces: @@ -68,12 +68,36 @@ Code Suggestions includes [Google Vertex AI Codey APIs](https://cloud.google.com Suggestion quality for other languages and using natural language code comments to request completions may not yet result in high-quality suggestions. +### Supported languages in IDEs + +Editor support for languages is documented in the following table. + +| Language | VS Code | JetBrains IDEs | Visual Studio | Neovim | +|---------------------------------|----------------------------------------------------------------------------------------------------------------|------------------------------|---------------|--------| +| C++ | ✓ | | ✓ | | +| C# | ✓ | ✓ | ✓ | | +| Go | ✓ | ✓ (IDEA Ultimate / GoLand) | | | +| Google SQL | | | | | +| Java | ✓ | ✓ | | | +| JavaScript | ✓ | ✓ | | | +| Kotlin | ✓ | ✓ | | | +| PHP | ✓ | ✓ (IDEA Ultimate) | | | +| Python | ✓ | ✓ | | ✓ | +| Ruby | ✓ | ✓ (IDEA Ultimate / RubyMine) | | ✓ | +| Rust | ✓ | ✓ | | | +| Scala | ✓ | ✓ | | | +| Swift | ✓ | ✓ | | | +| TypeScript | ✓ | ✓ | | | +| Google Cloud CLI | | | | | +| Kubernetes Resource Model (KRM) | | | | | +| Terraform | [`gitlab-org/gitlab-vscode-extension#826`](https://gitlab.com/gitlab-org/gitlab-vscode-extension/-/issues/826) | | | | + ## Supported editor extensions -Code Suggestions supports a variety of popular editors including: +Code Suggestions supports a variety of popular editors including: - VS Code, using [the VS Code GitLab Workflow extension](https://marketplace.visualstudio.com/items?itemName=GitLab.gitlab-workflow). -- [GitLab WebIDE (VS Code in the Cloud)](../../project/web_ide/index.md), with no additional configuration. +- [GitLab WebIDE (VS Code in the Cloud)](../../project/web_ide/index.md), with no additional configuration. - Microsoft Visual Studio, using the [Visual Studio GitLab extension](https://marketplace.visualstudio.com/items?itemName=GitLab.GitLabExtensionForVisualStudio). - JetBrains IDEs, using the [GitLab plugin](https://plugins.jetbrains.com/plugin/22325-gitlab). - Neovim, using the [`gitlab.vim` plugin](https://gitlab.com/gitlab-org/editor-extensions/gitlab.vim). @@ -83,7 +107,7 @@ is also in process. This improvement should result in: - Faster iteration and standardization of the IDE extensions. -- The ability to use Code Suggestions even when an official editor extension isn't available. +- The ability to use Code Suggestions even when an official editor extension isn't available. ## Enable Code Suggestions on GitLab SaaS **(FREE SAAS)** @@ -167,7 +191,7 @@ on self-managed instances. To request access: and tag your customer success manager. After GitLab has provisioned access to Code Suggestions for your instance, -the users in your instance can now enable Code Suggestions. +the users in your instance can now enable Code Suggestions. ## Use Code Suggestions diff --git a/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml index 6a701fdf644..7b2fb49b65e 100644 --- a/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml @@ -1,5 +1,5 @@ variables: - DAST_AUTO_DEPLOY_IMAGE_VERSION: 'v2.52.0' + DAST_AUTO_DEPLOY_IMAGE_VERSION: 'v2.53.0' .dast-auto-deploy: image: "${CI_TEMPLATE_REGISTRY_HOST}/gitlab-org/cluster-integration/auto-deploy-image:${DAST_AUTO_DEPLOY_IMAGE_VERSION}" diff --git a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml index f73cc8bb224..1e482ccca82 100644 --- a/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/Deploy.gitlab-ci.yml @@ -1,5 +1,5 @@ variables: - AUTO_DEPLOY_IMAGE_VERSION: 'v2.52.0' + AUTO_DEPLOY_IMAGE_VERSION: 'v2.53.0' .auto-deploy: image: "${CI_TEMPLATE_REGISTRY_HOST}/gitlab-org/cluster-integration/auto-deploy-image:${AUTO_DEPLOY_IMAGE_VERSION}" diff --git a/lib/gitlab/ci/templates/Jobs/Deploy.latest.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Deploy.latest.gitlab-ci.yml index 402c547e2ab..6eac691b293 100644 --- a/lib/gitlab/ci/templates/Jobs/Deploy.latest.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Jobs/Deploy.latest.gitlab-ci.yml @@ -1,5 +1,5 @@ variables: - AUTO_DEPLOY_IMAGE_VERSION: 'v2.52.0' + AUTO_DEPLOY_IMAGE_VERSION: 'v2.53.0' .auto-deploy: image: "${CI_TEMPLATE_REGISTRY_HOST}/gitlab-org/cluster-integration/auto-deploy-image:${AUTO_DEPLOY_IMAGE_VERSION}" diff --git a/lib/gitlab/database/postgresql_adapter/force_disconnectable_mixin.rb b/lib/gitlab/database/postgresql_adapter/force_disconnectable_mixin.rb index 71d2554844e..21392283ccf 100644 --- a/lib/gitlab/database/postgresql_adapter/force_disconnectable_mixin.rb +++ b/lib/gitlab/database/postgresql_adapter/force_disconnectable_mixin.rb @@ -11,6 +11,8 @@ module Gitlab end def force_disconnect_if_old! + return if Rails.env.test? && transaction_open? + if force_disconnect_timer.expired? disconnect! reset_force_disconnect_timer! diff --git a/locale/gitlab.pot b/locale/gitlab.pot index abbcbd91eeb..07ee85d2b78 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -48487,15 +48487,42 @@ msgstr "" msgid "TimeTracking|An error occurred while removing the timelog." msgstr "" +msgid "TimeTracking|An error occurred while saving the time estimate." +msgstr "" + msgid "TimeTracking|Delete time spent" msgstr "" +msgid "TimeTracking|Edit estimate" +msgstr "" + +msgid "TimeTracking|Edit time estimate" +msgstr "" + +msgid "TimeTracking|Enter time as a total duration (for example, 1mo 2w 3d 5h 10m), or specify hours and minutes (for example, 75:30)." +msgstr "" + +msgid "TimeTracking|Estimate" +msgstr "" + msgid "TimeTracking|Estimated:" msgstr "" +msgid "TimeTracking|How do I estimate and track time?" +msgstr "" + msgid "TimeTracking|Over by %{timeRemainingHumanReadable}" msgstr "" +msgid "TimeTracking|Set estimate" +msgstr "" + +msgid "TimeTracking|Set estimated time to complete this %{issuableTypeName}." +msgstr "" + +msgid "TimeTracking|Set time estimate" +msgstr "" + msgid "TimeTracking|Spent" msgstr "" @@ -53209,6 +53236,9 @@ msgstr "" msgid "WorkItem|Something went wrong when fetching work item types. Please try again" msgstr "" +msgid "WorkItem|Something went wrong when fetching work items. Please try again." +msgstr "" + msgid "WorkItem|Something went wrong when trying to add a child. Please try again." msgstr "" diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js deleted file mode 100644 index b01ee01120e..00000000000 --- a/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js +++ /dev/null @@ -1,67 +0,0 @@ -/* - To avoid duplicating tests in time_tracker.spec, - this spec only contains a simple test to check rendering. - - A detailed feature spec is used to test time tracking feature - in swimlanes sidebar. -*/ - -import { shallowMount } from '@vue/test-utils'; -import BoardSidebarTimeTracker from '~/boards/components/sidebar/board_sidebar_time_tracker.vue'; -import { createStore } from '~/boards/stores'; -import IssuableTimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; - -describe('BoardSidebarTimeTracker', () => { - let wrapper; - let store; - - const createComponent = (options) => { - wrapper = shallowMount(BoardSidebarTimeTracker, { - store, - ...options, - }); - }; - - beforeEach(() => { - store = createStore(); - store.state.boardItems = { - 1: { - id: 1, - iid: 1, - timeEstimate: 3600, - totalTimeSpent: 1800, - humanTimeEstimate: '1h', - humanTotalTimeSpent: '30min', - }, - }; - store.state.activeId = '1'; - }); - - it.each` - timeTrackingLimitToHours | canUpdate - ${true} | ${false} - ${true} | ${true} - ${false} | ${false} - ${false} | ${true} - `( - 'renders IssuableTimeTracker with correct spent and estimated time (timeTrackingLimitToHours=$timeTrackingLimitToHours, canUpdate=$canUpdate)', - ({ timeTrackingLimitToHours, canUpdate }) => { - createComponent({ provide: { timeTrackingLimitToHours, canUpdate } }); - - expect(wrapper.findComponent(IssuableTimeTracker).props()).toEqual({ - limitToHours: timeTrackingLimitToHours, - canAddTimeEntries: canUpdate, - showCollapsed: false, - issuableId: '1', - issuableIid: '1', - fullPath: '', - initialTimeTracking: { - timeEstimate: 3600, - totalTimeSpent: 1800, - humanTimeEstimate: '1h', - humanTotalTimeSpent: '30min', - }, - }); - }, - ); -}); diff --git a/spec/frontend/fixtures/time_estimates.rb b/spec/frontend/fixtures/time_estimates.rb new file mode 100644 index 00000000000..c9591bd63ac --- /dev/null +++ b/spec/frontend/fixtures/time_estimates.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Time estimates (GraphQL fixtures)', feature_category: :team_planning do + describe GraphQL::Query, type: :request do + include ApiHelpers + include GraphqlHelpers + include JavaScriptFixturesHelpers + + let_it_be(:developer) { create(:user) } + + context 'for issues time estimate' do + let_it_be(:project) { create(:project_empty_repo, :public) } + let_it_be(:issue) { create(:issue, project: project) } + + let(:query_path) { 'sidebar/queries/issue_set_time_estimate.mutation.graphql' } + let(:query) { get_graphql_query_as_string(query_path) } + + before_all do + project.add_developer(developer) + end + + context 'when there are no errors while changing the time estimate' do + it "graphql/issue_set_time_estimate_without_errors.json" do + post_graphql( + query, + current_user: developer, + variables: { + input: { + projectPath: project.full_path, + iid: issue.iid.to_s, + timeEstimate: '1d 2h' + } + } + ) + + expect_graphql_errors_to_be_empty + end + end + + context 'when there are errors while changing the time estimate' do + it "graphql/issue_set_time_estimate_with_errors.json" do + post_graphql( + query, + current_user: developer, + variables: { + input: { + projectPath: project.full_path, + iid: issue.iid.to_s, + timeEstimate: '1egh' + } + } + ) + + expect_graphql_errors_to_include("timeEstimate must be formatted correctly, for example `1h 30m`") + end + end + end + end +end diff --git a/spec/frontend/milestones/index_spec.js b/spec/frontend/milestones/index_spec.js index 477217fc10f..1f65cfc556e 100644 --- a/spec/frontend/milestones/index_spec.js +++ b/spec/frontend/milestones/index_spec.js @@ -6,6 +6,7 @@ jest.mock('~/behaviors/markdown/render_gfm'); jest.mock('~/milestones/milestone'); jest.mock('~/right_sidebar'); jest.mock('~/sidebar/mount_milestone_sidebar'); +jest.mock('~/lib/graphql'); describe('#initShow', () => { beforeEach(() => { diff --git a/spec/frontend/sidebar/components/time_tracking/set_time_estimate_form_spec.js b/spec/frontend/sidebar/components/time_tracking/set_time_estimate_form_spec.js new file mode 100644 index 00000000000..657fb52d62c --- /dev/null +++ b/spec/frontend/sidebar/components/time_tracking/set_time_estimate_form_spec.js @@ -0,0 +1,416 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlModal, GlAlert } from '@gitlab/ui'; +import setIssueTimeEstimateWithErrors from 'test_fixtures/graphql/issue_set_time_estimate_with_errors.json'; +import setIssueTimeEstimateWithoutErrors from 'test_fixtures/graphql/issue_set_time_estimate_without_errors.json'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import SetTimeEstimateForm from '~/sidebar/components/time_tracking/set_time_estimate_form.vue'; +import issueSetTimeEstimateMutation from '~/sidebar/queries/issue_set_time_estimate.mutation.graphql'; + +const mockProjectFullPath = 'group/project'; +const mockMutationErrorMessage = setIssueTimeEstimateWithErrors.errors[0].message; +const mockIssuableIid = '1'; +const mockMutationTimeEstimateInHumanReadableFormat = '1d 2h'; +const mockTimeTrackingData = { + timeEstimate: 3600, + humanTimeEstimate: '1h', +}; + +const resolvedMutationWithoutErrorsMock = jest + .fn() + .mockResolvedValue(setIssueTimeEstimateWithoutErrors); +const resolvedMutationWithErrorsMock = jest.fn().mockResolvedValue(setIssueTimeEstimateWithErrors); + +const rejectedMutationMock = jest.fn().mockRejectedValue(); +const modalCloseMock = jest.fn(); + +describe('Set Time Estimate Form', () => { + Vue.use(VueApollo); + + let wrapper; + + const findModal = () => wrapper.findComponent(GlModal); + const findModalTitle = () => findModal().props('title'); + const findAlert = () => wrapper.findComponent(GlAlert); + const findDocsLink = () => wrapper.findByTestId('timetracking-docs-link'); + const findSaveButton = () => findModal().props('actionPrimary'); + const findSaveButtonLoadingState = () => findSaveButton().attributes.loading; + const findSaveButtonDisabledState = () => findSaveButton().attributes.disabled; + const findResetButton = () => findModal().props('actionSecondary'); + const findResetButtonLoadingState = () => findResetButton().attributes.loading; + const findResetButtonDisabledState = () => findResetButton().attributes.disabled; + const findTimeEstiamteInput = () => wrapper.findByTestId('time-estimate'); + + const triggerSave = () => { + const mockEvent = { preventDefault: jest.fn() }; + findModal().vm.$emit('primary', mockEvent); + }; + + const triggerReset = () => { + const mockEvent = { preventDefault: jest.fn() }; + findModal().vm.$emit('secondary', mockEvent); + }; + + const mountComponent = async ({ + timeTracking = mockTimeTrackingData, + data, + providedProps, + mutationResolverMock = resolvedMutationWithoutErrorsMock, + } = {}) => { + wrapper = shallowMountExtended(SetTimeEstimateForm, { + data() { + return { + ...data, + }; + }, + provide: { + issuableType: 'issue', + ...providedProps, + }, + propsData: { + issuableIid: mockIssuableIid, + fullPath: mockProjectFullPath, + timeTracking, + }, + apolloProvider: createMockApollo([[issueSetTimeEstimateMutation, mutationResolverMock]]), + }); + + wrapper.vm.$refs.modal.close = modalCloseMock; + + findModal().vm.$emit('show'); + await nextTick(); + }; + + describe('modal title', () => { + it('is `Set time estimate` when the current estimate is 0', async () => { + await mountComponent({ + timeTracking: { timeEstimate: 0, humanTimeEstimate: '0h' }, + mutationResolverMock: resolvedMutationWithoutErrorsMock, + }); + + expect(findModalTitle()).toBe('Set time estimate'); + }); + + it('is `Edit time estimate` when the current estimate is not 0', async () => { + await mountComponent(); + + expect(findModalTitle()).toBe('Edit time estimate'); + }); + }); + + describe('modal', () => { + it('shows the provided human time estimate from the timeTracking prop', async () => { + await mountComponent(); + + expect(findTimeEstiamteInput().attributes('value')).toBe( + mockTimeTrackingData.humanTimeEstimate, + ); + }); + }); + + describe('save button', () => { + it('is not loading by default', async () => { + await mountComponent(); + + expect(findSaveButtonLoadingState()).toBe(false); + }); + + it('is disabled and not loading when time estimate is empty', async () => { + await mountComponent({ data: { timeEstimate: '' } }); + + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(true); + }); + + it('is enabled and not loading when time estimate is not empty', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + }); + + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(false); + }); + + it('is disabled and loading when the the save button is clicked', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + }); + + triggerSave(); + + await nextTick(); + + expect(findSaveButtonLoadingState()).toBe(true); + expect(findSaveButtonDisabledState()).toBe(true); + }); + + it('is disabled and loading when the the reset button is clicked', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + }); + + triggerReset(); + + await nextTick(); + + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(true); + }); + + it('is enabled and not loading the when the save button is clicked and the mutation had errors', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + mutationResolverMock: rejectedMutationMock, + }); + + triggerSave(); + + await waitForPromises(); + + expect(rejectedMutationMock).toHaveBeenCalledWith({ + input: { + projectPath: mockProjectFullPath, + iid: mockIssuableIid, + timeEstimate: mockMutationTimeEstimateInHumanReadableFormat, + }, + }); + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(false); + }); + + it('is enabled and not loading the when save button is clicked and the mutation returns errors', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + mutationResolverMock: resolvedMutationWithErrorsMock, + }); + + triggerSave(); + + await waitForPromises(); + + expect(resolvedMutationWithErrorsMock).toHaveBeenCalledWith({ + input: { + projectPath: mockProjectFullPath, + iid: mockIssuableIid, + timeEstimate: mockMutationTimeEstimateInHumanReadableFormat, + }, + }); + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(false); + }); + + it('closes the modal after submission and the mutation did not return any error', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + mutationResolverMock: resolvedMutationWithoutErrorsMock, + }); + + triggerSave(); + + await waitForPromises(); + + expect(resolvedMutationWithoutErrorsMock).toHaveBeenCalledWith({ + input: { + projectPath: mockProjectFullPath, + iid: mockIssuableIid, + timeEstimate: mockMutationTimeEstimateInHumanReadableFormat, + }, + }); + expect(modalCloseMock).toHaveBeenCalled(); + }); + }); + + describe('reset button', () => { + it('is not visible when the current estimate is 0', async () => { + await mountComponent({ + timeTracking: { timeEstimate: 0, humanTimeEstimate: '0h' }, + mutationResolverMock: resolvedMutationWithoutErrorsMock, + }); + + expect(findResetButton()).toBe(null); + }); + + it('is enabled and not loading even if time estimate is empty', async () => { + await mountComponent({ data: { timeEstimate: '' } }); + + expect(findResetButtonLoadingState()).toBe(false); + expect(findResetButtonDisabledState()).toBe(false); + }); + + it('is enabled and not loading when time estimate is not empty', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + }); + + expect(findResetButtonLoadingState()).toBe(false); + expect(findResetButtonDisabledState()).toBe(false); + }); + + it('is disabled and loading when the the reset button is clicked', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + }); + + triggerReset(); + + await nextTick(); + + expect(findResetButtonLoadingState()).toBe(true); + expect(findResetButtonDisabledState()).toBe(true); + }); + + it('is disabled and loading when the the save button is clicked', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + }); + + triggerSave(); + + await nextTick(); + + expect(findResetButtonLoadingState()).toBe(false); + expect(findResetButtonDisabledState()).toBe(true); + }); + + it('is enabled and not loading the when the reset button is clicked and the mutation had errors', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + mutationResolverMock: rejectedMutationMock, + }); + + triggerReset(); + + await waitForPromises(); + + expect(rejectedMutationMock).toHaveBeenCalledWith({ + input: { + projectPath: mockProjectFullPath, + iid: mockIssuableIid, + timeEstimate: '0', + }, + }); + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(false); + }); + + it('is enabled and not loading the when reset button is clicked and the mutation returns errors', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + mutationResolverMock: resolvedMutationWithErrorsMock, + }); + + triggerReset(); + + await waitForPromises(); + + expect(resolvedMutationWithErrorsMock).toHaveBeenCalledWith({ + input: { + projectPath: mockProjectFullPath, + iid: mockIssuableIid, + timeEstimate: '0', + }, + }); + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(false); + }); + + it('closes the modal after submission and the mutation did not return any error', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + mutationResolverMock: resolvedMutationWithoutErrorsMock, + }); + + triggerReset(); + + await waitForPromises(); + await nextTick(); + + expect(resolvedMutationWithoutErrorsMock).toHaveBeenCalledWith({ + input: { + projectPath: mockProjectFullPath, + iid: mockIssuableIid, + timeEstimate: '0', + }, + }); + expect(modalCloseMock).toHaveBeenCalled(); + }); + }); + + describe('alert', () => { + it('is hidden by default', async () => { + await mountComponent(); + + expect(findAlert().exists()).toBe(false); + }); + + describe('when saving a change', () => { + it('shows an error if the submission fails with a handled error', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + mutationResolverMock: resolvedMutationWithErrorsMock, + }); + + triggerSave(); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(mockMutationErrorMessage); + }); + + it('shows an error if the submission fails with an unhandled error', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + mutationResolverMock: rejectedMutationMock, + }); + + triggerSave(); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe('An error occurred while saving the time estimate.'); + }); + }); + + describe('when resetting the time estimate', () => { + it('shows an error if the submission fails with a handled error', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + mutationResolverMock: resolvedMutationWithErrorsMock, + }); + + triggerReset(); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(mockMutationErrorMessage); + }); + + it('shows an error if the submission fails with an unhandled error', async () => { + await mountComponent({ + data: { timeEstimate: mockMutationTimeEstimateInHumanReadableFormat }, + mutationResolverMock: rejectedMutationMock, + }); + + triggerReset(); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe('An error occurred while saving the time estimate.'); + }); + }); + }); + + describe('docs link message', () => { + it('is present', async () => { + await mountComponent(); + + expect(findDocsLink().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js index e23d24f9629..f43fb17ca37 100644 --- a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js @@ -291,6 +291,68 @@ describe('Issuable Time Tracker', () => { }, ); }); + + describe('Set time estimate button', () => { + const findSetTimeEstimateButton = () => findByTestId('set-time-estimate-button'); + + it.each` + visibility | canSetTimeEstimate + ${'not visible'} | ${false} + ${'visible'} | ${true} + `( + 'is $visibility when canSetTimeEstimate is $canSetTimeEstimate', + async ({ canSetTimeEstimate }) => { + wrapper = mountComponent({ + props: { + initialTimeTracking: { + timeEstimate: 0, + totalTimeSpent: 0, + humanTimeEstimate: '', + humanTotalTimeSpent: '', + }, + canSetTimeEstimate, + }, + }); + await nextTick(); + + expect(findSetTimeEstimateButton().exists()).toBe(canSetTimeEstimate); + }, + ); + + it('shows a tooltip with `Set estimate` when the current estimate is 0', async () => { + wrapper = mountComponent({ + props: { + initialTimeTracking: { + timeEstimate: 0, + totalTimeSpent: 0, + humanTimeEstimate: '', + humanTotalTimeSpent: '', + }, + canSetTimeEstimate: true, + }, + }); + await nextTick(); + + expect(findSetTimeEstimateButton().attributes('title')).toBe('Set estimate'); + }); + + it('shows a tooltip with `Edit estimate` when the current estimate is not 0', async () => { + wrapper = mountComponent({ + props: { + initialTimeTracking: { + timeEstimate: 60, + totalTimeSpent: 0, + humanTimeEstimate: '1m', + humanTotalTimeSpent: '', + }, + canSetTimeEstimate: true, + }, + }); + await nextTick(); + + expect(findSetTimeEstimateButton().attributes('title')).toBe('Edit estimate'); + }); + }); }); describe('Event listeners', () => { diff --git a/spec/frontend/work_items/list/components/work_items_list_app_spec.js b/spec/frontend/work_items/list/components/work_items_list_app_spec.js index 10d9fea8a06..c92d092eb43 100644 --- a/spec/frontend/work_items/list/components/work_items_list_app_spec.js +++ b/spec/frontend/work_items/list/components/work_items_list_app_spec.js @@ -1,15 +1,33 @@ +import * as Sentry from '@sentry/browser'; import { shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import { STATUS_OPEN } from '~/issues/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import WorkItemsListApp from '~/work_items/list/components/work_items_list_app.vue'; +import getWorkItemsQuery from '~/work_items/list/queries/get_work_items.query.graphql'; +import { groupWorkItemsQueryResponse } from '../../mock_data'; + +jest.mock('@sentry/browser'); describe('WorkItemsListApp component', () => { let wrapper; + Vue.use(VueApollo); + + const defaultQueryHandler = jest.fn().mockResolvedValue(groupWorkItemsQueryResponse); + const findIssuableList = () => wrapper.findComponent(IssuableList); - const mountComponent = () => { - wrapper = shallowMount(WorkItemsListApp); + const mountComponent = ({ queryHandler = defaultQueryHandler } = {}) => { + wrapper = shallowMount(WorkItemsListApp, { + apolloProvider: createMockApollo([[getWorkItemsQuery, queryHandler]]), + provide: { + fullPath: 'full/path', + }, + }); }; it('renders IssuableList component', () => { @@ -17,13 +35,51 @@ describe('WorkItemsListApp component', () => { expect(findIssuableList().props()).toMatchObject({ currentTab: STATUS_OPEN, + error: '', issuables: [], namespace: 'work-items', recentSearchesStorageKey: 'issues', searchInputPlaceholder: 'Search or filter results...', searchTokens: [], + showWorkItemTypeIcon: true, sortOptions: [], tabs: WorkItemsListApp.issuableListTabs, }); }); + + it('renders work items', async () => { + mountComponent(); + await waitForPromises(); + + expect(findIssuableList().props('issuables')).toEqual( + groupWorkItemsQueryResponse.data.group.workItems.nodes, + ); + }); + + it('fetches work items', () => { + mountComponent(); + + expect(defaultQueryHandler).toHaveBeenCalledWith({ fullPath: 'full/path' }); + }); + + describe('when there is an error fetching work items', () => { + beforeEach(async () => { + mountComponent({ queryHandler: jest.fn().mockRejectedValue(new Error('ERROR')) }); + await waitForPromises(); + }); + + it('renders an error message', () => { + const message = 'Something went wrong when fetching work items. Please try again.'; + + expect(findIssuableList().props('error')).toBe(message); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('ERROR')); + }); + + it('clears error message when "dismiss-alert" event is emitted from IssuableList', async () => { + findIssuableList().vm.$emit('dismiss-alert'); + await nextTick(); + + expect(findIssuableList().props('error')).toBe(''); + }); + }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 0c5ce179acc..05e83c0df3d 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -3304,3 +3304,63 @@ export const getTodosMutationResponse = (state) => { }, }; }; + +export const groupWorkItemsQueryResponse = { + data: { + group: { + id: 'gid://gitlab/Group/3', + workItems: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/58', + iid: '23', + author: { + id: 'gid://gitlab/User/9', + avatarUrl: 'author/avatar/url', + name: 'Arthur', + username: 'arthur', + webUrl: 'author/web/url', + }, + closedAt: '', + confidential: true, + createdAt: '2020-01-23T12:34:56Z', + reference: 'javascriptjs/js#23', + state: 'OPEN', + title: 'a group level work item', + updatedAt: '', + webUrl: 'web/url', + widgets: [ + { + __typename: 'WorkItemWidgetAssignees', + assignees: { + nodes: mockAssignees, + }, + type: 'ASSIGNEES', + }, + { + __typename: 'WorkItemWidgetLabels', + allowsScopedLabels: false, + labels: { + nodes: [ + { + __typename: 'Label', + id: 'gid://gitlab/Label/7', + color: '#f00', + description: '', + title: 'Label 7', + }, + ], + }, + type: 'LABELS', + }, + ], + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Issue', + }, + }, + ], + }, + }, + }, +}; diff --git a/spec/lib/gitlab/database/postgresql_adapter/force_disconnectable_mixin_spec.rb b/spec/lib/gitlab/database/postgresql_adapter/force_disconnectable_mixin_spec.rb index 399fcae2fa0..3650ca1d904 100644 --- a/spec/lib/gitlab/database/postgresql_adapter/force_disconnectable_mixin_spec.rb +++ b/spec/lib/gitlab/database/postgresql_adapter/force_disconnectable_mixin_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::Database::PostgresqlAdapter::ForceDisconnectableMixin, :reestablished_active_record_base do +RSpec.describe Gitlab::Database::PostgresqlAdapter::ForceDisconnectableMixin, :delete, :reestablished_active_record_base do describe 'checking in a connection to the pool' do let(:model) do Class.new(ActiveRecord::Base) do @@ -32,14 +32,29 @@ RSpec.describe Gitlab::Database::PostgresqlAdapter::ForceDisconnectableMixin, :r let(:timer) { connection.force_disconnect_timer } context 'when the timer is expired' do - it 'disconnects from the database' do + before do allow(timer).to receive(:expired?).and_return(true) + end + it 'disconnects from the database' do expect(connection).to receive(:disconnect!).and_call_original expect(timer).to receive(:reset!).and_call_original connection.force_disconnect_if_old! end + + context 'when the connection has an open transaction' do + it 'does not disconnect from the database' do + connection.begin_transaction + + expect(connection).not_to receive(:disconnect!) + expect(timer).not_to receive(:reset!) + + connection.force_disconnect_if_old! + + connection.rollback_transaction + end + end end context 'when the timer is not expired' do diff --git a/spec/support/database/auto_explain.rb b/spec/support/database/auto_explain.rb index 108d88e37b9..799457034a1 100644 --- a/spec/support/database/auto_explain.rb +++ b/spec/support/database/auto_explain.rb @@ -115,11 +115,16 @@ module AutoExplain private def record_auto_explain?(connection) - ENV['CI'] \ - && ENV['CI_MERGE_REQUEST_LABELS']&.include?('pipeline:record-queries') \ - && ENV['CI_JOB_NAME_SLUG'] != 'db-migrate-non-superuser' \ - && connection.database_version.to_s[0..1].to_i >= 14 \ - && connection.select_one('SHOW is_superuser')['is_superuser'] == 'on' + return false unless ENV['CI'] + return false if ENV['CI_JOB_NAME_SLUG'] == 'db-migrate-non-superuser' + return false if connection.database_version.to_s[0..1].to_i < 14 + return false if connection.select_one('SHOW is_superuser')['is_superuser'] != 'on' + + # This condition matches the pipeline rules for if-merge-request-labels-record-queries + return true if ENV['CI_MERGE_REQUEST_LABELS']&.include?('pipeline:record-queries') + + # This condition matches the pipeline rules for if-default-branch-refs + ENV['CI_COMMIT_REF_NAME'] == ENV['CI_DEFAULT_BRANCH'] && !ENV['CI_MERGE_REQUEST_IID'] end end end diff --git a/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb index 56a1cee44c8..344f827dbb2 100644 --- a/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb +++ b/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb @@ -90,6 +90,15 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type| end end + it 'shows the set time estimate form when add button is clicked' do + click_button _('Set estimate') + + page.within '[data-testid="set-time-estimate-modal"]' do + expect(page).to have_content 'Set time estimate' + expect(page).to have_content 'Estimate' + end + end + it 'shows the time tracking report when link is clicked' do submit_time('/estimate 1w') submit_time('/spend 1d')