diff --git a/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget.vue b/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget.vue index 55e4de8183b3d0642698948783f3b61c618a3e59..28e70cc5636e20c8241306fd520b9b439a6ebde1 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget.vue @@ -1,7 +1,16 @@ <script> -import { GlButton, GlForm, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui'; +import { + GlButton, + GlForm, + GlLoadingIcon, + GlCollapsibleListbox, + GlTooltipDirective, +} from '@gitlab/ui'; import { isEmpty, debounce } from 'lodash'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { sanitize } from '~/lib/dompurify'; +import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle'; +import { keysFor } from '~/behaviors/shortcuts/keybindings'; import { s__, __, sprintf } from '~/locale'; @@ -19,6 +28,9 @@ export default { GlForm, GlCollapsibleListbox, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { canUpdate: { type: Boolean, @@ -108,6 +120,11 @@ export default { required: false, default: __('Search'), }, + shortcut: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { return { @@ -131,6 +148,22 @@ export default { ? sprintf(__(`No %{label}`), { label: this.dropdownLabel.toLowerCase() }) : this.toggleDropdownText; }, + disableShortcuts() { + return shouldDisableShortcuts() || Object.keys(this.shortcut).length === 0; + }, + shortcutDescription() { + return this.disableShortcuts ? null : this.shortcut.description; + }, + shortcutKey() { + return this.disableShortcuts ? null : keysFor(this.shortcut)[0]; + }, + tooltipText() { + const description = this.shortcutDescription; + const key = this.shortcutKey; + return this.disableShortcuts + ? null + : sanitize(`${description} <kbd class="flat gl-ml-1" aria-hidden=true>${key}</kbd>`); + }, }, watch: { itemValue: { @@ -198,6 +231,8 @@ export default { <gl-loading-icon v-if="updateInProgress" /> <gl-button v-if="canUpdate && !isEditing" + v-gl-tooltip.viewport.html + :title="tooltipText" data-testid="edit-button" category="tertiary" size="small" diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 73f882bc94678244c593298cf582a615ddeeba5d..9a0c646381e7361ec6bf68fd8a1ef81b382ec5f1 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -10,6 +10,7 @@ import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_ import WorkItemSidebarDropdownWidget from '~/work_items/components/shared/work_item_sidebar_dropdown_widget.vue'; import { s__, sprintf, __ } from '~/locale'; import Tracking from '~/tracking'; +import { ISSUE_MR_CHANGE_ASSIGNEE } from '~/behaviors/shortcuts/keybindings'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import updateNewWorkItemMutation from '../graphql/update_new_work_item.mutation.graphql'; import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; @@ -79,6 +80,7 @@ export default { currentUser: null, updateInProgress: false, localUsers: [], + shortcut: ISSUE_MR_CHANGE_ASSIGNEE, }; }, apollo: { @@ -337,6 +339,7 @@ export default { :header-text="headerText" :update-in-progress="updateInProgress" :reset-button-label="__('Clear')" + :shortcut="shortcut" clear-search-on-item-select data-testid="work-item-assignees" @dropdownShown="onDropdownShown" diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index 5267b10620d6a51758d04672c59988e427252602..c60c93dee0a8cba2ba7b0be92efcc45e08655226 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -18,6 +18,9 @@ import { TYPENAME_GROUP } from '~/graphql_shared/constants'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { WORKSPACE_PROJECT } from '~/issues/constants'; import { addShortcutsExtension } from '~/behaviors/shortcuts'; +import { sanitize } from '~/lib/dompurify'; +import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle'; +import { keysFor, ISSUABLE_EDIT_DESCRIPTION } from '~/behaviors/shortcuts/keybindings'; import ShortcutsWorkItems from '~/behaviors/shortcuts/shortcuts_work_items'; import { i18n, @@ -462,6 +465,16 @@ export default { shouldShowEditButton() { return !this.editMode && this.canUpdate; }, + editShortcutKey() { + return shouldDisableShortcuts() ? null : keysFor(ISSUABLE_EDIT_DESCRIPTION)[0]; + }, + editTooltip() { + const description = __('Edit title and description'); + const key = this.editShortcutKey; + return shouldDisableShortcuts() + ? description + : sanitize(`${description} <kbd class="flat gl-ml-1" aria-hidden=true>${key}</kbd>`); + }, modalCloseButtonClass() { return { 'sm:gl-hidden': !this.error, @@ -933,6 +946,8 @@ export default { <div class="gl-ml-auto gl-mt-1 gl-flex gl-gap-3 gl-self-start"> <gl-button v-if="shouldShowEditButton" + v-gl-tooltip.bottom.html + :title="editTooltip" category="secondary" data-testid="work-item-edit-form-button" class="shortcut-edit-wi-description" 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 d48dcae9529c98a646c4aeb53b05e81756b3590b..ec84595eeef4b0dad7bf27f959e14429b57f02a1 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -10,6 +10,7 @@ import groupLabelsQuery from '~/sidebar/components/labels/labels_select_widget/g import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql'; import { isScopedLabel } from '~/lib/utils/common_utils'; import Tracking from '~/tracking'; +import { ISSUABLE_CHANGE_LABEL } from '~/behaviors/shortcuts/keybindings'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import updateNewWorkItemMutation from '../graphql/update_new_work_item.mutation.graphql'; @@ -72,6 +73,7 @@ export default { addLabelIds: [], labelsCache: [], labelsToShowAtTopOfTheListbox: [], + shortcut: ISSUABLE_CHANGE_LABEL, }; }, computed: { @@ -334,6 +336,7 @@ export default { :toggle-dropdown-text="dropdownText" :header-text="__('Select labels')" :reset-button-label="__('Clear')" + :shortcut="shortcut" show-footer multi-select clear-search-on-item-select diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue index 26eb8a9abe1bc38884b4a187a27234f293a419c8..8a7fd3ba8aa319246618d6e2e0c9857d35482ca0 100644 --- a/app/assets/javascripts/work_items/components/work_item_milestone.vue +++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue @@ -7,6 +7,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { s__, __ } from '~/locale'; import { MILESTONE_STATE } from '~/sidebar/constants'; import WorkItemSidebarDropdownWidget from '~/work_items/components/shared/work_item_sidebar_dropdown_widget.vue'; +import { ISSUE_MR_CHANGE_MILESTONE } from '~/behaviors/shortcuts/keybindings'; import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; import groupMilestonesQuery from '~/sidebar/queries/group_milestones.query.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; @@ -68,6 +69,7 @@ export default { updateInProgress: false, milestones: [], localMilestone: this.workItemMilestone, + shortcut: ISSUE_MR_CHANGE_MILESTONE, }; }, computed: { @@ -235,6 +237,7 @@ export default { :toggle-dropdown-text="dropdownText" :header-text="__('Select milestone')" :reset-button-label="__('Clear')" + :shortcut="shortcut" data-testid="work-item-milestone" @dropdownShown="onDropdownShown" @searchStarted="search" diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index f611bfacc527375177341bb287272c0f93ae3b57..ef31153232b4c1544ac2c4ff11fe88d92493f747 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -173,12 +173,14 @@ def extensions_marketplace_view end def build_extensions_marketplace_view(title:, message:) - extensions_marketplace_home = "%{linkStart}#{::WebIde::ExtensionMarketplace.marketplace_home_url}%{linkEnd}" + marketplace_home_url = ::WebIde::ExtensionMarketplace.marketplace_home_url(user: current_user) + + extensions_marketplace_home = "%{linkStart}#{marketplace_home_url}%{linkEnd}" { name: 'extensions_marketplace', message: format(message, extensions_marketplace_home: extensions_marketplace_home), title: title, - message_url: WebIde::ExtensionMarketplace.marketplace_home_url, + message_url: marketplace_home_url, help_link: WebIde::ExtensionMarketplace.help_preferences_url } end diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb index 6f5914c52af4a7fe4ec7b66116bfb2ddf0e4675c..66f0e37814fbb7410708217110426763e511533c 100644 --- a/app/models/hooks/web_hook_log.rb +++ b/app/models/hooks/web_hook_log.rb @@ -19,7 +19,12 @@ class WebHookLog < ApplicationRecord self.primary_key = :id - partitioned_by :created_at, strategy: :monthly, retain_for: 1.month + # The partitioning definition here has been temporarily moved to config/initializers/postgres_partitioning.rb + # so that it does not interact with the changing WebHookLog.table_name as influenced by the + # ::Ci::Partitionable::Switch module. + # In config/initializers/postgres_partitioning.rb: + # - web_hook_logs_daily is registered for daily partitioning for when the flag is ON + # - web_hook_logs is registered for monthly partitioning for when the flag is OFF belongs_to :web_hook diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 2b0875fd4b14ef7a828f0d2b40402e9ae79b0b02..b448cf5443f2b869b59f25945bd694c548a8b3dd 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -10,7 +10,7 @@ - rich_text_editor_help_text = s_('Preferences|Type in rich text, as you see it.') - @color_modes = Gitlab::ColorModes::available_modes.to_json - @themes = Gitlab::Themes::available_themes.to_json -- extensions_marketplace_url = ::WebIde::ExtensionMarketplace.marketplace_home_url +- extensions_marketplace_url = ::WebIde::ExtensionMarketplace.marketplace_home_url(user: current_user) - data_attributes = { color_modes: @color_modes, themes: @themes, integration_views: integration_views.to_json, user_fields: user_fields, body_classes: Gitlab::Themes.body_classes, profile_preferences_path: profile_preferences_path, extensions_marketplace_url: extensions_marketplace_url } - @force_desktop_expanded_sidebar = true diff --git a/config/initializers/postgres_partitioning.rb b/config/initializers/postgres_partitioning.rb index 9ec06212e82bf9d004155715379b2b6cfb99b967..638fef511d7d29b4020073d86ec0d6bc683c33ce 100644 --- a/config/initializers/postgres_partitioning.rb +++ b/config/initializers/postgres_partitioning.rb @@ -35,8 +35,9 @@ Gitlab::Database::BackgroundMigration::BatchedJobTransitionLog, LooseForeignKeys::DeletedRecord, Users::GroupVisit, - Users::ProjectVisit, - WebHookLog + Users::ProjectVisit + # WebHookLog is temporarily removed from this list and managed without a model + # during the switch from web_hook_logs to web_hook_logs_daily ]) if Gitlab.ee? @@ -84,12 +85,20 @@ # Enable partition management for the backfill table during web_hook_logs partitioning. # This way new partitions will be created as the trigger syncs new rows across to this table. +# We're controlling the table backing WebHookLog with the feature flag web_hook_logs_daily_enabled. +# So that the feature flag does not interact with the partition manager, register both web_hook_logs tables here, +# disconnected from the feature flag. Gitlab::Database::Partitioning.register_tables( [ { limit_connection_names: %i[main], table_name: 'web_hook_logs_daily', partitioned_column: :created_at, strategy: :daily, retain_for: 14.days + }, + { + limit_connection_names: %i[main], + table_name: 'web_hook_logs', + partitioned_column: :created_at, strategy: :monthly, retain_for: 1.month } ] ) diff --git a/doc/administration/geo/replication/img/geo-ha-diagram_v12.png b/doc/administration/geo/replication/img/geo-ha-diagram_v11_11.png similarity index 100% rename from doc/administration/geo/replication/img/geo-ha-diagram_v12.png rename to doc/administration/geo/replication/img/geo-ha-diagram_v11_11.png diff --git a/doc/administration/geo/replication/multiple_servers.md b/doc/administration/geo/replication/multiple_servers.md index 6bc9968b18320b88b3a1b2fac1a58d22da21e3e7..b39456600968b3e9374d7bdeddd54175f9f5909d 100644 --- a/doc/administration/geo/replication/multiple_servers.md +++ b/doc/administration/geo/replication/multiple_servers.md @@ -18,7 +18,7 @@ described, it is possible to adapt these instructions to your needs. ## Architecture overview - + _[diagram source - GitLab employees only](https://docs.google.com/drawings/d/1z0VlizKiLNXVVVaERFwgsIOuEgjcUqDTWPdQYsE7Z4c/edit)_ diff --git a/doc/administration/geo/secondary_proxy/_index.md b/doc/administration/geo/secondary_proxy/_index.md index eeef06bc6d615a51b672ce4292fcacd02f42c403..a89b01c792127938d1ac6df6b449dd46d1a621fa 100644 --- a/doc/administration/geo/secondary_proxy/_index.md +++ b/doc/administration/geo/secondary_proxy/_index.md @@ -252,7 +252,7 @@ Disabling the proxying feature flag has the following general effects: - The secondary site does not proxy HTTP requests to the primary site. Instead, it attempts to serve them itself, or fail. - Git requests generally succeed. Git pushes are redirected or proxied to the primary site. - Other than Git requests, any HTTP request which may write data fails. Read requests generally succeed. -- The secondary site UI shows a banner:  +- The secondary site UI shows a banner:  | Feature / component | Succeed | Notes | | :-------------------------------------------------- | :------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/doc/administration/geo/secondary_proxy/img/secondary_proxy_read_only_v17.8.png b/doc/administration/geo/secondary_proxy/img/secondary_proxy_read_only_v17_8.png similarity index 100% rename from doc/administration/geo/secondary_proxy/img/secondary_proxy_read_only_v17.8.png rename to doc/administration/geo/secondary_proxy/img/secondary_proxy_read_only_v17_8.png diff --git a/doc/administration/packages/container_registry_troubleshooting.md b/doc/administration/packages/container_registry_troubleshooting.md index 5c65bf2beca9ab60b8eabd7cdb698244ba694ed7..7b020f52958d9671273821317a9dc855248a8c61 100644 --- a/doc/administration/packages/container_registry_troubleshooting.md +++ b/doc/administration/packages/container_registry_troubleshooting.md @@ -5,19 +5,42 @@ info: To determine the technical writer assigned to the Stage/Group associated w title: Troubleshooting the container registry --- -Before diving in to the following sections, here's some basic troubleshooting: +Before investigating specific issues, try these troubleshooting steps: -1. Check to make sure that the system clock on your Docker client and GitLab server have - been synchronized (for example, via NTP). +1. Verify that the system clock on your Docker client and GitLab server are synchronized (for example, through NTP). -1. If you are using an S3-backed Registry, double check that the IAM - permissions and the S3 credentials (including region) are correct. See - [the sample IAM policy](https://distribution.github.io/distribution/storage-drivers/s3/) - for more details. +1. For S3-backed registries, verify your IAM permissions and S3 credentials (including region) are correct. + For more information, see the [sample IAM policy](https://distribution.github.io/distribution/storage-drivers/s3/). -1. Check the Registry logs (for example `/var/log/gitlab/registry/current`) and the GitLab production logs - for errors (for example `/var/log/gitlab/gitlab-rails/production.log`). You may be able to find clues - there. +1. Check for errors in the registry logs (for example, `/var/log/gitlab/registry/current`) and the GitLab production logs + (for example, `/var/log/gitlab/gitlab-rails/production.log`). + +1. Review the NGINX configuration file for the container registry (for example, `/var/opt/gitlab/nginx/conf/gitlab-registry.conf`) + to confirm which port receives requests. + +1. Verify that requests are correctly forwarded to the container registry: + + ```shell + curl --verbose --noproxy "*" https://<hostname>:<port>/v2/_catalog + ``` + + The response should include a line with `Www-Authenticate: Bearer` containing `service="container_registry"`. For example: + + ```plaintext + < HTTP/1.1 401 Unauthorized + < Server: nginx + < Date: Fri, 07 Mar 2025 08:24:43 GMT + < Content-Type: application/json + < Content-Length: 162 + < Connection: keep-alive + < Docker-Distribution-Api-Version: registry/2.0 + < Www-Authenticate: Bearer realm="https://<hostname>/jwt/auth",service="container_registry",scope="registry:catalog:*" + < X-Content-Type-Options: nosniff + < + {"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail": + [{"Type":"registry","Class":"","Name":"catalog","ProjectPath":"","Action":"*"}]}]} + * Connection #0 to host <hostname> left intact + ``` ## Using self-signed certificates with container registry diff --git a/doc/integration/gmail_action_buttons_for_gitlab.md b/doc/integration/gmail_action_buttons_for_gitlab.md index 8e2d9b161396ef3b3dbcc20b213e80f5195f8d38..328b31bd9e0eb16d3c6c4f24f7269eb4278f0e45 100644 --- a/doc/integration/gmail_action_buttons_for_gitlab.md +++ b/doc/integration/gmail_action_buttons_for_gitlab.md @@ -15,8 +15,6 @@ title: Gmail actions GitLab supports [Google actions in email](https://developers.google.com/gmail/markup/actions/actions-overview). When you configure this integration, emails that require an action are marked in Gmail. - - To get this functioning, you must be registered with Google. For instructions, see [Register with Google](https://developers.google.com/gmail/markup/registering-with-google). diff --git a/doc/integration/img/gmail_action_buttons_for_gitlab_v8.png b/doc/integration/img/gmail_action_buttons_for_gitlab_v8.png deleted file mode 100644 index 0e3e24d6ffc6cb95228d2bf0300f787cdc4f580d..0000000000000000000000000000000000000000 Binary files a/doc/integration/img/gmail_action_buttons_for_gitlab_v8.png and /dev/null differ diff --git a/doc/user/application_security/dast/browser/configuration/customize_settings.md b/doc/user/application_security/dast/browser/configuration/customize_settings.md index 21fc39eaa2b093ea0da87cca18b3e6ac6c2c5542..2ab2920e8f85920cf83549d87b55011718fbe7f1 100644 --- a/doc/user/application_security/dast/browser/configuration/customize_settings.md +++ b/doc/user/application_security/dast/browser/configuration/customize_settings.md @@ -181,7 +181,7 @@ each step in the process: 1. Parse content and renders the initial page. 1. Trigger the standard "document ready" event. - This phase uses either `DAST_PAGE_READY_AFTER_NAVIGATION_TIMEOUT` (for full page loads) or `DAST_PAGE_READY_AFTER_ACTION_TIMEOUT` (for in-page actions), which sets the maximum wait time for document loading. + This phase uses either `DAST_PAGE_READY_AFTER_NAVIGATION_TIMEOUT` (for full page loads) or `DAST_PAGE_READY_AFTER_ACTION_TIMEOUT` (for in-page actions), which sets the maximum wait time for document loading. 1. **Client-Side rendering**: After initial loading, many single-page applications: @@ -190,7 +190,7 @@ each step in the process: - Render a DOM and performs updates based on fetched data (`DAST_PAGE_DOM_READY_TIMEOUT`). - Display page loading indicators (`DAST_PAGE_IS_LOADING_ELEMENT`). - The scanner monitors these activities to determine when the page is ready for interaction. + The scanner monitors these activities to determine when the page is ready for interaction. The following chart illustrates the sequence timeouts used when crawling a page: diff --git a/doc/user/gitlab_duo/tutorials/fix_code_python_shop.md b/doc/user/gitlab_duo/tutorials/fix_code_python_shop.md new file mode 100644 index 0000000000000000000000000000000000000000..c701d42f29dde6366ee9f52cea8204e5fc089de7 --- /dev/null +++ b/doc/user/gitlab_duo/tutorials/fix_code_python_shop.md @@ -0,0 +1,1414 @@ +--- +stage: none +group: Tutorials +description: Tutorial on how to create a shop application in Python with GitLab Duo. +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments +--- + +# Tutorial: Use GitLab Duo to create a shop application in Python + +<!-- vale gitlab_base.FutureTense = NO --> + +You have been hired as a developer at an online bookstore. The current system for +managing inventory is a mix of spreadsheets and manual processes, leading to inventory +errors and delayed updates. Your team needs to create a web application that can: + +- Track book inventory in real time. +- Enable staff to add new books as they arrive. +- Prevent common data entry errors, like negative prices or quantities. +- Provide a foundation for future customer-facing features. + +This tutorial guides you through creating and debugging a [Python](https://www.python.org/) +web application with a database backend that meets these requirements. + +You'll use [GitLab Duo Chat](../../gitlab_duo_chat/_index.md) +and [GitLab Duo Code Suggestions](../../project/repository/code_suggestions/_index.md) +to help you: + +- Set up an organized Python project with standard directories and essential files. +- Configure the Python virtual environment. +- Install the [Flask](https://flask.palletsprojects.com/en/stable/) framework as the foundation + for the web application. +- Install required dependencies, and prepare the project for development. +- Set up the Python configuration file and environment variables for Flask application + development. +- Implement core features, including article models, database operations, + API routes, and inventory management features. +- Test that the application works as intended, comparing your code with example code files. + +## Before you begin + +- [Install the latest version of Python](https://www.python.org/downloads/) on your system. + You can ask Chat how to do that for your operating system. +- Make sure your organization has purchased a + [GitLab Duo add-on subscription (either Duo Pro or Duo Enterprise)](https://about.gitlab.com/gitlab-duo/#pricing), + and your administrator has [assigned you a seat](../../../subscriptions/subscription-add-ons.md#assign-gitlab-duo-seats). +- Install an extension in your preferred IDE: + - [Web IDE](../../project/web_ide/_index.md): Access through your GitLab instance + - [VS Code](../../../editor_extensions/visual_studio_code/setup.md) + - [Visual Studio](../../../editor_extensions/visual_studio/setup.md) + - [JetBrains IDE](../../../editor_extensions/jetbrains_ide/_index.md) + - [Neovim](../../../editor_extensions/neovim/setup.md) +- Authenticate with GitLab from the IDE, using either + [OAuth](../../../integration/google.md) or a + [personal access token with the `api` scope](../../profile/personal_access_tokens.md#create-a-personal-access-token). + +## Use GitLab Duo Chat and Code Suggestions + +In this tutorial, you will use Chat and Code Suggestions to create the Python +web application. Multiple ways exist to use these features. + +### Use GitLab Duo Chat + +You can use Chat in the GitLab UI, the Web IDE, or in your IDE. + +#### Use Chat in the GitLab UI + +1. In the upper-right corner, select **GitLab Duo Chat**. A drawer opens on the + right side of your browser tab. +1. Enter your question in the chat input box. Press **Enter**, or select **Send**. + It might take a few seconds for the interactive AI chat to produce an answer. + +#### Use Chat in the Web IDE + +1. Open the Web IDE: + 1. In the GitLab UI, on the left sidebar, select **Search or go to** and find your project. + 1. Select a file. Then in the upper right, select **Edit > Open in Web IDE**. +1. Open Chat by using one of these methods: + - On the left sidebar, select **GitLab Duo Chat**. + - In the file that you have open in the editor, select some code. + 1. Right-click and select **GitLab Duo Chat**. + 1. Select **Explain selected code**, **Generate Tests**, or **Refactor**. + - Use the keyboard shortcut: <kbd>ALT</kbd>+<kbd>d</kbd> (on Windows and Linux) or + <kbd>Option</kbd>+<kbd>d</kbd> (on Mac). +1. In the message box, enter your question. Press **Enter**, or select **Send**. + +#### Use Chat in your IDE + +How you use Chat in your IDE differs depending on which IDE you use. + +{{< tabs >}} + +{{< tab title="VS Code" >}} + +1. In VS Code, open a file. The file does not need to be a file in a Git repository. +1. On the left sidebar, select **GitLab Duo Chat** ({{< icon name="duo-chat" >}}). +1. In the message box, enter your question. Press **Enter**, or select **Send**. +1. In the chat pane, on the top right corner, select **Show Status** to show information + in the Command Palette. + +You can also interact with Duo Chat while you're working with a subset of code. + +1. In VS Code, open a file. The file does not need to be a file in a Git repository. +1. In the file, select some code. +1. Right-click and select **GitLab Duo Chat**. +1. Select an option, or **Open Quick Chat** and ask a question, like + `Can you simplify this code?` and press <kbd>Enter</kbd>. + +For more information, see [Use GitLab Duo Chat in VS Code](../../gitlab_duo_chat/_index.md#use-gitlab-duo-chat-in-vs-code). + +{{< /tab >}} + +{{< tab title="JetBrains IDEs" >}} + +1. Open a project in a JetBrains IDE that supports Python, such as + [PyCharm](https://www.jetbrains.com/pycharm/), or [IntelliJ IDEA](https://www.jetbrains.com/idea/). +1. Open GitLab Duo Chat in either a [chat window](../../gitlab_duo_chat/_index.md#in-a-chat-window) + or an [editor window](../../gitlab_duo_chat/_index.md#in-the-editor-window). + +For more information, see [Use GitLab Duo Chat in JetBrains IDEs](../../gitlab_duo_chat/_index.md#use-gitlab-duo-chat-in-jetbrains-ides). + +{{< /tab >}} + +{{< /tabs >}} + +### Use Code Suggestions + +To use Code Suggestions: + +1. Open your Git project in a + [supported IDE](../../project/repository/code_suggestions/supported_extensions.md#supported-editor-extensions). +1. Add the project as a remote of your local repository using + [`git remote add`](../../../topics/git/commands.md#git-remote-add). +1. Add your project directory, including the hidden `.git/` folder, to your IDE workspace or project. +1. Author your code. + As you type, suggestions are displayed. Code Suggestions provides code snippets + or completes the current line, depending on the cursor position. + +1. Describe the requirements in natural language. + Code Suggestions generates functions and code snippets based on the context provided. + +1. When you receive a suggestion, you can do any of the following: + - To accept a suggestion, press <kbd>Tab</kbd>. + - To accept a partial suggestion, press either <kbd>Control</kbd>+<kbd>Right arrow</kbd> or + <kbd>Command</kbd>+<kbd>Right arrow</kbd>. + - To reject a suggestion, press <kbd>Esc</kbd>. + - To ignore a suggestion, keep typing as you usually would. + +For more information, see the [Code Suggestions documentation](../../project/repository/code_suggestions/_index.md). + +Now that you know how to use Chat and Code Suggestions, let's start building the +web application. First, you will create an organized Python project structure. + +## Create the project structure + +To start with, you need a well-organized project structure that follows +Python best practices. A proper structure makes your code more maintainable, testable, +and easier for other developers to understand. + +You can use Chat to help you understand Python project organization conventions +and generate the appropriate files. This saves you time researching best practices, and +ensures you do not miss critical components. + +1. Open Chat in your IDE and enter: + + ```plaintext + What is the recommended project structure for a Python web application? Include + common files, and explain the purpose of each file. + ``` + + This prompt helps you understand Python project organization before creating files. + +1. Create a new folder for the Python project and create a directory and file structure + based on Chat's response. It will probably be similar to the following: + + ```plaintext + python-shop-app/ + ├── LICENSE + ├── README.md + ├── requirements.txt + ├── setup.py + ├── .gitignore + ├── .env + ├── app/ + │ ├── __init__.py + │ ├── models/ + │ │ ├── __init__.py + │ │ └── article.py + │ ├── routes/ + │ │ ├── __init__.py + │ │ └── shop.py + │ └── database.py + └── tests/ + ├── __init__.py + └── test_shop.py + ``` + +1. You must populate the `.gitignore` file. Enter the following into Chat: + + ```plaintext + Generate a .gitignore file for a Python project that uses Flask, SQLite, and + virtual environments. Include common IDE files. + ``` + +1. Copy the response into the `.gitignore` file. + +1. For the `README` file, enter the following into Chat: + + ```plaintext + Generate a README.md file for a Python web application that manages a bookstore + inventory. Make sure that it includes all sections for requirements, setup, and usage. + ``` + +You have now created a properly-structured Python project that follows industry +best practices. This organization makes your code easier to maintain and test. +Next, you'll set up your development environment to start writing code. + +## Set up the development environment + +A properly isolated development environment prevents dependency conflicts and makes +your application deployable. + +You will use Chat to help you set up a Python virtual environment and create a +`requirements.txt` file with the right dependencies. This ensures you have a stable +foundation for development. + +```plaintext + python-shop-app/ + ├── LICENSE + ├── README.md + ├── requirements.txt <= File you are updating + ├── setup.py + ├── .gitignore + ├── .env + ├── app/ + │ ├── __init__.py + │ ├── models/ + │ │ ├── __init__.py + │ │ └── article.py + │ ├── routes/ + │ │ ├── __init__.py + │ │ └── shop.py + │ └── database.py + └── tests/ + ├── __init__.py + └── test_shop.py +``` + +1. Optional. Ask Chat about how Python and Flask work together to produce + web applications. + +1. Use Chat to understand the best practices for setting up a Python environment: + + ```plaintext + What are the recommended steps for setting up a Python virtual environment with + Flask? Include information about requirements.txt and pip. + ``` + + Ask any follow-up questions that you need to. For example: + + ```plaintext + What does the requirements.txt do in a Python web app? + ``` + +1. Based on the response, first create and activate a virtual environment + (for example, on MacOS using Homebrew's `python3` package): + + ```plaintext + python3 -m venv myenv + source myenv/bin/activate + ``` + +1. You must also create a `requirements.txt` file. Ask Chat the following: + + ```plaintext + What should be included in requirements.txt for a Flask web application with + SQLite database and testing capabilities? Include specific version numbers. + ``` + + Copy the response to the `requirements.txt` file. + +1. Install the dependencies named in the `requirements.txt` file: + + ```plaintext + pip install -r requirements.txt + ``` + +Your development environment is now configured with all necessary dependencies +isolated in a virtual environment to prevent conflicts. Next, you'll configure +the project's package and environment settings. + +## Configure the project + +Proper configuration, including environment variables, enables your application +to run consistently across different environments. + +You'll use Code Suggestions to help generate and refine the configuration. +Then, you'll ask Chat to explain the purpose of each setting, so you understand +what you're configuring and why. + +1. You have already created a Python configuration file called `setup.py` in your + project folder: + + ```plaintext + python-shop-app/ + ├── LICENSE + ├── README.md + ├── requirements.txt + ├── setup.py <= File you are updating + ├── .gitignore + ├── .env + ├── app/ + │ ├── __init__.py + │ ├── models/ + │ │ ├── __init__.py + │ │ └── article.py + │ ├── routes/ + │ │ ├── __init__.py + │ │ └── shop.py + │ └── database.py + └── tests/ + ├── __init__.py + └── test_shop.py + ``` + + Open this file, and enter this comment at the top of the file: + + ```plaintext + # Populate this setup.py configuration file for a Flask web application + # Include dependencies for Flask, testing, and database functionality + # Use semantic versioning + ``` + + Code Suggestions generates the configuration for you. + +1. Optional. Select the generated configuration code and use the following + [slash commands](../../gitlab_duo_chat/examples.md#gitlab-duo-chat-slash-commands): + + - Use [`/explain`](../../gitlab_duo_chat/examples.md#explain-selected-code) + to understand what each configuration setting does. + - Use [`/refactor`](../../gitlab_duo_chat/examples.md#refactor-code-in-the-ide) + to identify potential improvements in the configuration structure. + +1. Review and adjust the generated code as needed. + + If you are not sure what you can adjust in the configuration file, ask Chat. + + If you want to ask Chat what to adjust, do so in the IDE in the `setup.py` + file, instead of in the GitLab UI. This provides Chat with + [the context you're working in](../../gitlab_duo_chat/_index.md#the-context-chat-is-aware-of), + including the `setup.py` file you just created. + + ```plaintext + You have used Code Suggestions to generate a Python configuration file, `setup.py`, + for a Flask web application. This file includes dependencies for Flask, testing, + and database functionality. If I were to review this file, what might I want + to change and adjust? + ``` + +1. Save the file. + +### Set the environment variables + +Next, you're going to use both Chat and Code Suggestions to set the environment variables. + +1. In Chat, ask the following: + + ```plaintext + In a Python project, what environment variables should be set for a Flask application in development mode? Include database configuration. + ``` + +1. You have already created a `.env` file to store environment variables. + + ```plaintext + python-shop-app/ + ├── LICENSE + ├── README.md + ├── requirements.txt + ├── setup.py + ├── .gitignore + ├── .env <= File you are updating + ├── app/ + │ ├── __init__.py + │ ├── models/ + │ │ ├── __init__.py + │ │ └── article.py + │ ├── routes/ + │ │ ├── __init__.py + │ │ └── shop.py + │ └── database.py + └── tests/ + ├── __init__.py + └── test_shop.py + ``` + + Open this file, and enter the following comment at the top of the file, including + the environment variables that Chat recommended: + + ```plaintext + # Populate this .env file to store environment variables + # Include the following + # ... + # Use semantic versioning + ``` + +1. Review and adjust the generated code as needed, and save the file. + +You have configured your project and set the environment variables. This ensures +your application can be deployed consistently across different environments. Next, +you'll create the application code for the inventory system. + +## Create the application code + +The Flask web framework has three core components: + +- Models: Contains the data and business logic, and the database model. Specified in the `article.py` file. +- Views: Handles HTTP requests and responses. Specified in the `shop.py` file. +- Controller: Manages data storage and retrieval. Specified in the `database.py` file. + +You will use Chat and Code Suggestions to help you define each of these three components +in three files in your Python project structure: + +- `article.py` defines the models component, specifically the database model. +- `shop.py` defines the views component, specifically the API routes. +- `database.py` defines the controller component. + +### Create the article file to define the database model + +Your bookstore needs database models and operations to manage inventory effectively. + +To create the application code for the bookstore inventory system, you will +use an article file to define the database model for articles. + +You will use Code Suggestions to help generate the code, and Chat to +implement best practices for data modeling and database management. + +1. You have already created an `article.py` file: + + ```plaintext + python-shop-app/ + ├── LICENSE + ├── README.md + ├── requirements.txt + ├── setup.py + ├── .gitignore + ├── .env + ├── app/ + │ ├── __init__.py + │ ├── models/ + │ │ ├── __init__.py + │ │ └── article.py <= File you are updating + │ ├── routes/ + │ │ ├── __init__.py + │ │ └── shop.py + │ └── database.py + └── tests/ + ├── __init__.py + └── test_shop.py + ``` + + In this file, use Code Suggestions + and enter the following: + + ```plaintext + # Create an Article class for a bookstore inventory system + # Include fields for: name, price, quantity + # Add data validation for each field + # Add methods to convert to/from dictionary format + ``` + +1. Optional. Use the following [slash commands](../../gitlab_duo_chat/examples.md#gitlab-duo-chat-slash-commands): + + - Use [`/explain`](../../gitlab_duo_chat/examples.md#explain-selected-code) + to understand how the article class works and its design patterns. + - Use, [`/refactor`](../../gitlab_duo_chat/examples.md#refactor-code-in-the-ide) + to identify potential improvements in the class structure and methods. + +1. Review and adjust the generated code as needed, and save the file. + +Next you will define the API routes. + +### Create the shop file to define the API routes + +Now that you have created the article file to define the database model, you will +create the API routes. + +API routes are crucial for a web application because they: + +- Define the public API for clients to interact with your application. +- Map HTTP requests to the appropriate code in your application. +- Handle input validation and error responses. +- Transform data between your internal models and the JSON format expected by API clients. + +For your bookstore inventory system, these routes will allow staff to: + +- View all books in inventory. +- Look up specific books by ID. +- Add new books as they arrive. +- Update book information such as price or quantity. +- Remove books that are no longer needed. + +In Flask, routes are functions that handle requests to specific URL endpoints. +For example, a route for `GET /books` would return a list of all books, while +`POST /books` would add a new book to the inventory. + +You will use Chat and Code Suggestions to create these routes in the `shop.py` +file that you've already set up in your project structure: + +```plaintext +python-shop-app/ +├── LICENSE +├── README.md +├── requirements.txt +├── setup.py +├── .gitignore +├── .env +├── app/ +│ ├── __init__.py +│ ├── models/ +│ │ ├── __init__.py +│ │ └── article.py +│ ├── routes/ +│ │ ├── __init__.py +│ │ └── shop.py <= File you are updating +│ └── database.py +└── tests/ + ├── __init__.py + └── test_shop.py +``` + +#### Create the Flask application and routes + +1. Open the `shop.py` file. To use Code Suggestions, enter this comment + at the top of the file: + + ```plaintext + # Create Flask routes for a bookstore inventory system + # Include routes for: + # - Getting all books (GET /books) + # - Getting a single book by ID (GET /books/<id>) + # - Adding a new book (POST /books) + # - Updating a book (PUT /books/<id>) + # - Deleting a book (DELETE /books/<id>) + # Use the Article class from models.article and database from database.py + # Include proper error handling and HTTP status codes + ``` + +1. Review the generated code. It should include: + + - Import statements for Flask, request, and `jsonify`. + - Import statements for your Article class and database module. + - Route definitions for all CRUD operations (Create, Read, Update, Delete). + - Proper error handling and HTTP status codes. + +1. Optional. Use these slash commands: + + - Use [`/explain`](../../gitlab_duo_chat/examples.md#explain-selected-code) + to understand how the Flask routing works. + - Use [`/refactor`](../../gitlab_duo_chat/examples.md#refactor-code-in-the-ide) + to identify potential improvements. + +1. If the generated code doesn't fully meet your needs, or you want to understand + how to improve it, you can ask Chat from within the `shop.py` file: + + ```plaintext + Can you suggest improvements for my Flask routes in this shop.py file? + I want to ensure that: + 1. The routes follow RESTful API design principles + 2. Responses include appropriate HTTP status codes + 3. Input validation is handled properly + 4. The code follows Flask best practices + ``` + +1. You also need to create the Flask application instance in the `__init__.py` + file inside the `app` directory. Open this file and use Code Suggestions to + generate the appropriate code: + + ```plaintext + # Create a Flask application factory + # Configure the app with settings from environment variables + # Register the shop blueprint + # Return the configured app + ``` + +1. Save both files. + +### Create the database file to manage data storage and retrieval + +Finally, you will create the database operations code. You have already created +a `database.py` file: + +```plaintext + python-shop-app/ + ├── LICENSE + ├── README.md + ├── requirements.txt + ├── setup.py + ├── .gitignore + ├── .env + ├── app/ + │ ├── __init__.py + │ ├── models/ + │ │ ├── __init__.py + │ │ └── article.py + │ ├── routes/ + │ │ ├── __init__.py + │ │ └── shop.py + │ └── database.py <= File you are updating + └── tests/ + ├── __init__.py + └── test_shop.py +``` + +1. Enter the following into Chat: + + ```plaintext + Generate a Python class that manages SQLite database operations for a bookstore inventory. Include: + - Context manager for connections + - Table creation + - CRUD operations + - Error handling + Show the complete code with comments. + ``` + +1. Review and adjust the generated code as needed, and save the file. + +You have successfully created the foundational code for your inventory +management system and defined the core components +of a Python web application built using the Flask framework. + +Next you'll check your created code against example code files. + +## Check your code against example code files + +The following examples show complete, working code that should be similar to the +code you end up with after following the tutorial. + +{{< tabs >}} + +{{< tab title=".gitignore" >}} + +This file shows standard Python project exclusions: + +```plaintext +# Virtual Environment +myenv/ +venv/ +ENV/ +env/ +.venv/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# SQLite database files +*.db +*.sqlite +*.sqlite3 + +# Environment variables +.env +.env.local +.env.*.local + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +``` + +{{< /tab >}} + +{{< tab title="README.md" >}} + +A comprehensive `README` file with setup and usage instructions. + +```markdown +# Bookstore Inventory Management System + +A Python web application for managing bookstore inventory, built with Flask and SQLite. + +## Features + +- Track book inventory in real time. +- Add, update, and remove books. +- Data validation to prevent common errors. +- RESTful API for inventory management. + +## Requirements + +- Python 3.8 or higher. +- Flask 2.2.0 or higher. +- SQLite 3. + +## Installation + +1. Clone the repository: + + ```shell + git clone https://gitlab.com/your-username/python-shop-app.git + cd python-shop-app + ``` + +1. Create and activate a virtual environment: + + ```shell + python -m venv myenv + source myenv/bin/activate # On Windows: myenv\Scripts\activate + ``` + +1. Install dependencies: + + ```shell + pip install -r requirements.txt + ``` + +1. Set up environment variables: + + Copy `.env.example` to `.env` and modify as needed. + +## Usage + +1. Start the Flask application: + + ```shell + flask run + ``` + +1. The API will be available at `http://localhost:5000/` + +## API Endpoints + +- `GET /books` - Get all books +- `GET /books/<id>` - Get a specific book +- `POST /books` - Add a new book +- `PUT /books/<id>` - Update a book +- `DELETE /books/<id>` - Delete a book + +## Testing + +Run tests with `pytest`: + +```python +python -m pytest +``` + +{{< /tab >}} + +{{< tab title="requirements.txt" >}} + +Lists all required Python packages with versions. + +```plaintext +Flask==2.2.3 +pytest==7.3.1 +pytest-flask==1.2.0 +Flask-SQLAlchemy==3.0.3 +SQLAlchemy==2.0.9 +python-dotenv==1.0.0 +Werkzeug==2.2.3 +requests==2.28.2 +``` + +{{< /tab >}} + +{{< tab title="setup.py" >}} + +Project configuration for packaging. + +```python +from setuptools import setup, find_packages + +setup( + name="bookstore-inventory", + version="0.1.0", + packages=find_packages(), + include_package_data=True, + install_requires=[ + "Flask>=2.2.0", + "Flask-SQLAlchemy>=3.0.0", + "SQLAlchemy>=2.0.0", + "pytest>=7.0.0", + "pytest-flask>=1.2.0", + "python-dotenv>=1.0.0", + ], + python_requires=">=3.8", + author="Your Name", + author_email="your.email@example.com", + description="A Flask web application for managing bookstore inventory", + keywords="flask, inventory, bookstore", + url="https://gitlab.com/your-username/python-shop-app", + classifiers=[ + "Development Status :: 3 - Alpha", + "Environment :: Web Environment", + "Framework :: Flask", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + ], +) +``` + +{{< /tab >}} + +{{< tab title=".env" >}} + +Contains environment variables for the application. + +```plaintext +# Flask configuration +FLASK_APP=app +FLASK_ENV=development +FLASK_DEBUG=1 +SECRET_KEY=your-secret-key-change-in-production + +# Database configuration +DATABASE_URL=sqlite:///bookstore.db +TEST_DATABASE_URL=sqlite:///test_bookstore.db + +# Application settings +BOOK_TITLE_MAX_LENGTH=100 +MAX_PRICE=1000.00 +MAX_QUANTITY=1000 +``` + +{{< /tab >}} + +{{< tab title="app/models/article.py" >}} + +Article class with full validation. + +```python +class Article: + """Article class for a bookstore inventory system.""" + + def __init__(self, name, price, quantity, article_id=None): + """ + Initialize an article with validation. + + Args: + name (str): The name/title of the book + price (float): The price of the book + quantity (int): The quantity in stock + article_id (int, optional): The unique identifier for the article + + Raises: + ValueError: If any of the fields fail validation + """ + self.id = article_id + self.set_name(name) + self.set_price(price) + self.set_quantity(quantity) + + def set_name(self, name): + """ + Set the name with validation. + + Args: + name (str): The name/title of the book + + Raises: + ValueError: If name is empty or too long + """ + if not name or not isinstance(name, str): + raise ValueError("Book title cannot be empty and must be a string") + + if len(name) > 100: # Max length validation + raise ValueError("Book title cannot exceed 100 characters") + + self.name = name.strip() + + def set_price(self, price): + """ + Set the price with validation. + + Args: + price (float): The price of the book + + Raises: + ValueError: If price is negative or not a number + """ + try: + price_float = float(price) + except (ValueError, TypeError): + raise ValueError("Price must be a number") + + if price_float < 0: + raise ValueError("Price cannot be negative") + + if price_float > 1000: # Max price validation + raise ValueError("Price cannot exceed 1000") + + # Ensure price has at most 2 decimal places + self.price = round(price_float, 2) + + def set_quantity(self, quantity): + """ + Set the quantity with validation. + + Args: + quantity (int): The quantity in stock + + Raises: + ValueError: If quantity is negative or not an integer + """ + try: + quantity_int = int(quantity) + except (ValueError, TypeError): + raise ValueError("Quantity must be an integer") + + if quantity_int < 0: + raise ValueError("Quantity cannot be negative") + + if quantity_int > 1000: # Max quantity validation + raise ValueError("Quantity cannot exceed 1000") + + self.quantity = quantity_int + + def to_dict(self): + """ + Convert the article to a dictionary. + + Returns: + dict: Dictionary representation of the article + """ + return { + "id": self.id, + "name": self.name, + "price": self.price, + "quantity": self.quantity + } + + @classmethod + def from_dict(cls, data): + """ + Create an article from a dictionary. + + Args: + data (dict): Dictionary with article data + + Returns: + Article: New article instance + """ + article_id = data.get("id") + return cls( + name=data["name"], + price=data["price"], + quantity=data["quantity"], + article_id=article_id + ) +``` + +{{< /tab >}} + +{{< tab title="app/routes/shop.py" >}} + +Complete API endpoints with error handling. + +```python +from flask import Blueprint, request, jsonify, current_app +from app.models.article import Article +from app import database +import logging + +# Create a blueprint for the shop routes +shop_bp = Blueprint('shop', __name__, url_prefix='/books') + +# Set up logging +logger = logging.getLogger(__name__) + +@shop_bp.route('', methods=['GET']) +def get_all_books(): + """Get all books from the inventory.""" + try: + books = database.get_all_articles() + return jsonify([book.to_dict() for book in books]), 200 + except Exception as e: + logger.error(f"Error getting all books: {str(e)}") + return jsonify({"error": "Failed to retrieve books"}), 500 + +@shop_bp.route('/<int:book_id>', methods=['GET']) +def get_book(book_id): + """Get a specific book by ID.""" + try: + book = database.get_article_by_id(book_id) + if book: + return jsonify(book.to_dict()), 200 + return jsonify({"error": f"Book with ID {book_id} not found"}), 404 + except Exception as e: + logger.error(f"Error getting book {book_id}: {str(e)}") + return jsonify({"error": f"Failed to retrieve book {book_id}"}), 500 + +@shop_bp.route('', methods=['POST']) +def add_book(): + """Add a new book to the inventory.""" + data = request.get_json() + + if not data: + return jsonify({"error": "No data provided"}), 400 + + required_fields = ['name', 'price', 'quantity'] + for field in required_fields: + if field not in data: + return jsonify({"error": f"Missing required field: {field}"}), 400 + + try: + # Validate data by creating an Article object + new_book = Article( + name=data['name'], + price=data['price'], + quantity=data['quantity'] + ) + + # Save to database + book_id = database.add_article(new_book) + + # Return the created book + created_book = database.get_article_by_id(book_id) + return jsonify(created_book.to_dict()), 201 + + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + logger.error(f"Error adding book: {str(e)}") + return jsonify({"error": "Failed to add book"}), 500 + +@shop_bp.route('/<int:book_id>', methods=['PUT']) +def update_book(book_id): + """Update an existing book.""" + data = request.get_json() + + if not data: + return jsonify({"error": "No data provided"}), 400 + + try: + # Check if book exists + existing_book = database.get_article_by_id(book_id) + if not existing_book: + return jsonify({"error": f"Book with ID {book_id} not found"}), 404 + + # Update book properties + if 'name' in data: + existing_book.set_name(data['name']) + if 'price' in data: + existing_book.set_price(data['price']) + if 'quantity' in data: + existing_book.set_quantity(data['quantity']) + + # Save updated book + database.update_article(existing_book) + + # Return the updated book + updated_book = database.get_article_by_id(book_id) + return jsonify(updated_book.to_dict()), 200 + + except ValueError as e: + return jsonify({"error": str(e)}), 400 + except Exception as e: + logger.error(f"Error updating book {book_id}: {str(e)}") + return jsonify({"error": f"Failed to update book {book_id}"}), 500 + +@shop_bp.route('/<int:book_id>', methods=['DELETE']) +def delete_book(book_id): + """Delete a book from the inventory.""" + try: + # Check if book exists + existing_book = database.get_article_by_id(book_id) + if not existing_book: + return jsonify({"error": f"Book with ID {book_id} not found"}), 404 + + # Delete the book + database.delete_article(book_id) + + return jsonify({"message": f"Book with ID {book_id} deleted successfully"}), 200 + + except Exception as e: + logger.error(f"Error deleting book {book_id}: {str(e)}") + return jsonify({"error": f"Failed to delete book {book_id}"}), 500 +``` + +{{< /tab >}} + +{{< tab title="app/database.py" >}} + +Database operations with connection management. + +```python +import sqlite3 +import os +import logging +from contextlib import contextmanager +from app.models.article import Article + +# Set up logging +logger = logging.getLogger(__name__) + +# Get database path from environment variable or use default +DATABASE_PATH = os.environ.get('DATABASE_PATH', 'bookstore.db') + +@contextmanager +def get_db_connection(): + """ + Context manager for database connections. + Automatically handles connection opening, committing, and closing. + + Yields: + sqlite3.Connection: Database connection object + """ + conn = None + try: + conn = sqlite3.connect(DATABASE_PATH) + # Configure connection to return rows as dictionaries + conn.row_factory = sqlite3.Row + yield conn + conn.commit() + except sqlite3.Error as e: + if conn: + conn.rollback() + logger.error(f"Database error: {str(e)}") + raise + finally: + if conn: + conn.close() + +def initialize_database(): + """ + Initialize the database by creating the articles table if it doesn't exist. + """ + try: + with get_db_connection() as conn: + cursor = conn.cursor() + + # Create articles table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS articles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + price REAL NOT NULL, + quantity INTEGER NOT NULL + ) + ''') + + logger.info("Database initialized successfully") + except sqlite3.Error as e: + logger.error(f"Failed to initialize database: {str(e)}") + raise + +def add_article(article): + """ + Add a new article to the database. + + Args: + article (Article): Article object to add + + Returns: + int: ID of the newly added article + """ + try: + with get_db_connection() as conn: + cursor = conn.cursor() + + cursor.execute( + "INSERT INTO articles (name, price, quantity) VALUES (?, ?, ?)", + (article.name, article.price, article.quantity) + ) + + # Get the ID of the newly inserted article + article_id = cursor.lastrowid + logger.info(f"Added article with ID {article_id}") + return article_id + except sqlite3.Error as e: + logger.error(f"Failed to add article: {str(e)}") + raise + +def get_article_by_id(article_id): + """ + Get an article by its ID. + + Args: + article_id (int): ID of the article to retrieve + + Returns: + Article: Article object if found, None otherwise + """ + try: + with get_db_connection() as conn: + cursor = conn.cursor() + + cursor.execute("SELECT * FROM articles WHERE id = ?", (article_id,)) + row = cursor.fetchone() + + if row: + return Article( + name=row['name'], + price=row['price'], + quantity=row['quantity'], + article_id=row['id'] + ) + return None + except sqlite3.Error as e: + logger.error(f"Failed to get article {article_id}: {str(e)}") + raise + +def get_all_articles(): + """ + Get all articles from the database. + + Returns: + list: List of Article objects + """ + try: + with get_db_connection() as conn: + cursor = conn.cursor() + + cursor.execute("SELECT * FROM articles") + rows = cursor.fetchall() + + articles = [] + for row in rows: + article = Article( + name=row['name'], + price=row['price'], + quantity=row['quantity'], + article_id=row['id'] + ) + articles.append(article) + + return articles + except sqlite3.Error as e: + logger.error(f"Failed to get all articles: {str(e)}") + raise + +def update_article(article): + """ + Update an existing article in the database. + + Args: + article (Article): Article object with updated values + + Returns: + bool: True if successful, False if article not found + """ + try: + with get_db_connection() as conn: + cursor = conn.cursor() + + cursor.execute( + "UPDATE articles SET name = ?, price = ?, quantity = ? WHERE id = ?", + (article.name, article.price, article.quantity, article.id) + ) + + # Check if an article was actually updated + if cursor.rowcount == 0: + logger.warning(f"No article found with ID {article.id}") + return False + + logger.info(f"Updated article with ID {article.id}") + return True + except sqlite3.Error as e: + logger.error(f"Failed to update article {article.id}: {str(e)}") + raise + +def delete_article(article_id): + """ + Delete an article from the database. + + Args: + article_id (int): ID of the article to delete + + Returns: + bool: True if successful, False if article not found + """ + try: + with get_db_connection() as conn: + cursor = conn.cursor() + + cursor.execute("DELETE FROM articles WHERE id = ?", (article_id,)) + + # Check if an article was actually deleted + if cursor.rowcount == 0: + logger.warning(f"No article found with ID {article_id}") + return False + + logger.info(f"Deleted article with ID {article_id}") + return True + except sqlite3.Error as e: + logger.error(f"Failed to delete article {article_id}: {str(e)}") + raise +``` + +{{< /tab >}} +<!-- markdownlint-disable --> +{{< tab title="app/__init__.py" >}} +<!-- markdownlint-enable --> +Flask application factory. + +```python +import os +from flask import Flask +from dotenv import load_dotenv + +def create_app(test_config=None): + """ + Application factory for creating the Flask app. + + Args: + test_config (dict, optional): Test configuration to override default config + + Returns: + Flask: Configured Flask application + """ + # Load environment variables from .env file + load_dotenv() + + # Create and configure the app + app = Flask(__name__, instance_relative_config=True) + + # Set default configuration + app.config.from_mapping( + SECRET_KEY=os.environ.get('SECRET_KEY', 'dev'), + DATABASE_PATH=os.environ.get('DATABASE_URL', 'bookstore.db'), + BOOK_TITLE_MAX_LENGTH=int(os.environ.get('BOOK_TITLE_MAX_LENGTH', 100)), + MAX_PRICE=float(os.environ.get('MAX_PRICE', 1000.00)), + MAX_QUANTITY=int(os.environ.get('MAX_QUANTITY', 1000)) + ) + + # Override config with test config if provided + if test_config: + app.config.update(test_config) + + # Ensure the instance folder exists + os.makedirs(app.instance_path, exist_ok=True) + + # Initialize database + from app import database + database.initialize_database() + + # Register blueprints + from app.routes.shop import shop_bp + app.register_blueprint(shop_bp) + + # Add a simple index route + @app.route('/') + def index(): + return { + "message": "Welcome to the Bookstore Inventory API", + "endpoints": { + "books": "/books", + "book_by_id": "/books/<id>" + } + } + + return app +``` + +{{< /tab >}} + +{{< /tabs >}} + +1. Check your code files against these examples. + +1. To verify if your code works, ask Chat how to start a local application server: + + ```plaintext + How do I start a local application server for my Python web application? + ``` + +1. Follow the instructions, and check if your application is working. + +If your application is working, congratulations! You have successfully used +GitLab Duo Chat and Code Suggestions to build working online shop application. + +If it is not working, then you need to find out why. Chat and Code Suggestions +can help you create tests to ensure your application works as expected and +identify any issues that need to be fixed. +[Issue 1284](https://gitlab.com/gitlab-org/technical-writing/team-tasks/-/issues/1284) +exists to create this tutorial. + +## Related topics + +- [GitLab Duo use cases](../use_cases.md) +- [Get started with GitLab Duo](../../get_started/getting_started_gitlab_duo.md). +- Blog post: [Streamline DevSecOps engineering workflows with GitLab Duo](https://about.gitlab.com/blog/2024/12/05/streamline-devsecops-engineering-workflows-with-gitlab-duo/) +<!-- markdownlint-disable --> +- <i class="fa-youtube-play" aria-hidden="true"></i> + [GitLab Duo Chat](https://youtu.be/ZQBAuf-CTAY?si=0o9-xJ_ATTsL1oew) +<!-- Video published on 2024-04-18 --> +- <i class="fa-youtube-play" aria-hidden="true"></i> + [GitLab Duo Code Suggestions](https://youtu.be/ds7SG1wgcVM?si=MfbzPIDpikGhoPh7) +<!-- Video published on 2024-01-24 --> +<!-- markdownlint-enable --> diff --git a/doc/user/group/saml_sso/example_saml_config.md b/doc/user/group/saml_sso/example_saml_config.md index f775eca24a8dce56b9bae5ef6f821efd39d4a811..2a812da0a370ca60a1bd98bc07d03e41a7453d05 100644 --- a/doc/user/group/saml_sso/example_saml_config.md +++ b/doc/user/group/saml_sso/example_saml_config.md @@ -134,19 +134,19 @@ Setting the username for the newly provisioned users when assigning them the SCI ### Basic SAML app configuration - + ### Parameters - + ### Adding a user - + ### SSO settings - + ## SAML response example diff --git a/doc/user/group/saml_sso/img/OneLogin-SSOsettings_v12.png b/doc/user/group/saml_sso/img/OneLogin-SSOsettings_v12_8.png similarity index 100% rename from doc/user/group/saml_sso/img/OneLogin-SSOsettings_v12.png rename to doc/user/group/saml_sso/img/OneLogin-SSOsettings_v12_8.png diff --git a/doc/user/group/saml_sso/img/OneLogin-app_details_v12.png b/doc/user/group/saml_sso/img/OneLogin-app_details_v12_8.png similarity index 100% rename from doc/user/group/saml_sso/img/OneLogin-app_details_v12.png rename to doc/user/group/saml_sso/img/OneLogin-app_details_v12_8.png diff --git a/doc/user/group/saml_sso/img/OneLogin-parameters_v12.png b/doc/user/group/saml_sso/img/OneLogin-parameters_v12_8.png similarity index 100% rename from doc/user/group/saml_sso/img/OneLogin-parameters_v12.png rename to doc/user/group/saml_sso/img/OneLogin-parameters_v12_8.png diff --git a/doc/user/group/saml_sso/img/OneLogin-userAdd_v12.png b/doc/user/group/saml_sso/img/OneLogin-userAdd_v12_8.png similarity index 100% rename from doc/user/group/saml_sso/img/OneLogin-userAdd_v12.png rename to doc/user/group/saml_sso/img/OneLogin-userAdd_v12_8.png diff --git a/doc/user/project/repository/code_suggestions/_index.md b/doc/user/project/repository/code_suggestions/_index.md index 2073e0caaf0ed1a8ad3e52fe359f4e10d0d26a39..8e4fc23edd2fd76bc77d83c1384cd710540e5e40 100644 --- a/doc/user/project/repository/code_suggestions/_index.md +++ b/doc/user/project/repository/code_suggestions/_index.md @@ -251,6 +251,23 @@ that explain what you want to build: - Code generation treats your code comments like chat. - Your code comments update the `user_instruction`, and then improve the next results you receive. +### Using imported files as context + +{{< history >}} + +- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/514124) in GitLab 17.9 [with a flag](../../../../administration/feature_flags.md) named `code_suggestions_include_context_imports`. Disabled by default. + +{{< /history >}} + +{{< alert type="flag" >}} + +The availability of this feature is controlled by a feature flag. +For more information, see the history. + +{{< /alert >}} + +Use the imported files in your IDE to provide context about your code project. Imported file context is supported for JavaScript and TypeScript files, including [`js`], [`jsx`], [`ts`], [`tsx`], and [`vue`] file types. + ## Truncation of file content Because of LLM limits and performance reasons, the content of the currently diff --git a/ee/app/assets/javascripts/dependencies/components/dependency_export_dropdown.vue b/ee/app/assets/javascripts/dependencies/components/dependency_export_dropdown.vue index 80e1620b4ac934a0566012c1213e5ab507a5d750..3c4c886c83bf1a02ebbe642846841534dfbf6d01 100644 --- a/ee/app/assets/javascripts/dependencies/components/dependency_export_dropdown.vue +++ b/ee/app/assets/javascripts/dependencies/components/dependency_export_dropdown.vue @@ -8,7 +8,6 @@ import { // eslint-disable-next-line no-restricted-imports import { mapActions, mapState } from 'vuex'; import { s__ } from '~/locale'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { EXPORT_FORMAT_CSV, EXPORT_FORMAT_DEPENDENCY_LIST, @@ -39,13 +38,7 @@ const exportFormats = [ type: EXPORT_FORMAT_CSV, buttonText: s__('Dependencies|Export as CSV'), testid: 'csv-item', - available: (component) => { - if (component.container === NAMESPACE_GROUP) { - return component.glFeatures.groupDependencyListCsvExport; - } - - return availableForContainers([NAMESPACE_PROJECT, NAMESPACE_ORGANIZATION])(component); - }, + available: availableForContainers([NAMESPACE_PROJECT, NAMESPACE_GROUP, NAMESPACE_ORGANIZATION]), }, ]; @@ -59,7 +52,6 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [glFeatureFlagMixin()], props: { // Used in availability check. // eslint-disable-next-line vue/no-unused-properties diff --git a/ee/app/controllers/groups/dependencies_controller.rb b/ee/app/controllers/groups/dependencies_controller.rb index d3fd9059e505a3bc86cebd16bcb5e54d9da2a3c0..c96c7a512a1ac9a8a12c3b6fd2ad8b45177d10f6 100644 --- a/ee/app/controllers/groups/dependencies_controller.rb +++ b/ee/app/controllers/groups/dependencies_controller.rb @@ -9,7 +9,6 @@ class DependenciesController < Groups::ApplicationController push_frontend_feature_flag(:group_level_dependencies_filtering_by_packager, group) push_frontend_feature_flag(:version_filtering_on_group_level_dependency_list, group) push_frontend_feature_flag(:dependency_paths, group) - push_frontend_feature_flag(:group_dependency_list_csv_export, group) end before_action :authorize_read_dependency_list! diff --git a/ee/app/helpers/ee/preferences_helper.rb b/ee/app/helpers/ee/preferences_helper.rb index de40ade83ef3a7f7b65e5ad56cea43d4b25ad86b..cb4b9fd83d2b45738beaeb47f8e93ac7d7cdcc3c 100644 --- a/ee/app/helpers/ee/preferences_helper.rb +++ b/ee/app/helpers/ee/preferences_helper.rb @@ -15,6 +15,8 @@ def excluded_dashboard_choices override :extensions_marketplace_view def extensions_marketplace_view + return unless ::WebIde::ExtensionMarketplace.feature_enabled_from_application_settings?(user: current_user) + if License.feature_available?(:remote_development) && ::WebIde::ExtensionMarketplace.feature_enabled?(user: current_user) build_extensions_marketplace_view( diff --git a/ee/config/feature_flags/gitlab_com_derisk/group_dependency_list_csv_export.yml b/ee/config/feature_flags/gitlab_com_derisk/group_dependency_list_csv_export.yml deleted file mode 100644 index 538b15165a54fc17d20f2d654040e1e9364c24e1..0000000000000000000000000000000000000000 --- a/ee/config/feature_flags/gitlab_com_derisk/group_dependency_list_csv_export.yml +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: group_dependency_list_csv_export -feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/435843 -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/183097 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/523239 -milestone: '17.10' -group: group::security insights -type: gitlab_com_derisk -default_enabled: false diff --git a/ee/spec/factories/remote_development/workspaces.rb b/ee/spec/factories/remote_development/workspaces.rb index 30a8f19d269c46b3cdeea3c74a511cbde85fee14..c3695013d18bb27663a184138e3e99c6f5209f88 100644 --- a/ee/spec/factories/remote_development/workspaces.rb +++ b/ee/spec/factories/remote_development/workspaces.rb @@ -84,8 +84,7 @@ user_name: workspace.user.name, user_email: workspace.user.email, workspace_id: workspace.id, - vscode_extension_marketplace: - WebIde::Settings::DefaultSettings.default_settings.fetch(:vscode_extension_marketplace).first, + vscode_extension_marketplace: ::WebIde::ExtensionMarketplacePreset.open_vsx.values, variables: [] ) diff --git a/ee/spec/frontend/dependencies/components/dependency_export_dropdown_spec.js b/ee/spec/frontend/dependencies/components/dependency_export_dropdown_spec.js index 0d475962621ad1d1d38f0617f4d8b22dfc38e64f..7edd8db981a6001fa9023b199a7a5ec6594b80cc 100644 --- a/ee/spec/frontend/dependencies/components/dependency_export_dropdown_spec.js +++ b/ee/spec/frontend/dependencies/components/dependency_export_dropdown_spec.js @@ -24,11 +24,8 @@ describe('DependencyExportDropdown component', () => { wrapper = shallowMountExtended(DependencyExportDropdown, { store, + provide, propsData: props, - provide: { - glFeatures: { groupDependencyListCsvExport: true }, - ...provide, - }, }); }; @@ -103,27 +100,6 @@ describe('DependencyExportDropdown component', () => { itHasCorrectLoadingLogic(() => findDisclosure()); itShowsDisclosureWithItems([jsonArrayItem, csvItem]); - - describe('when group CSV export feature flag is disabled', () => { - beforeEach(() => { - factory({ - props: { container: NAMESPACE_GROUP }, - provide: { glFeatures: { groupDependencyListCsvExport: false } }, - }); - }); - - it('shows button that dispatches JSON export', () => { - const button = findButton(); - - expect(button.exists()).toBe(true); - - button.vm.$emit('click'); - - expect(store.dispatch).toHaveBeenCalledWith(`${allNamespace}/fetchExport`, { - export_type: EXPORT_FORMAT_JSON_ARRAY, - }); - }); - }); }); describe('when container is an organization', () => { diff --git a/ee/spec/helpers/preferences_helper_spec.rb b/ee/spec/helpers/preferences_helper_spec.rb index a1ce1fc2d202104338263d066b5c1fad2249604e..4088e2d3774ec42d1f8e28aebabe301cc6f6f76f 100644 --- a/ee/spec/helpers/preferences_helper_spec.rb +++ b/ee/spec/helpers/preferences_helper_spec.rb @@ -48,17 +48,34 @@ end describe '#extensions_marketplace_view' do + let(:feature_enabled) { false } + let(:application_setting_enabled) { true } + subject { helper.extensions_marketplace_view } + before do + allow(::WebIde::ExtensionMarketplace).to receive(:feature_enabled?) + .with(user: user) + .and_return(feature_enabled) + + allow(::WebIde::ExtensionMarketplace).to receive(:feature_enabled_from_application_settings?) + .with(user: user) + .and_return(application_setting_enabled) + end + + context 'when feature not enabled at application_settings' do + let(:application_setting_enabled) { false } + + it { is_expected.to be_nil } + end + context 'when remote_development licensed feature is enabled' do before do stub_licensed_features(remote_development: true) end context 'when Web IDE Extension Marketplace feature is enabled' do - before do - allow(::WebIde::ExtensionMarketplace).to receive(:feature_enabled?).with(user: user).and_return(true) - end + let(:feature_enabled) { true } it { is_expected.to match(a_hash_including(title: 'Web IDE and Workspaces', message: /IDE and Workspaces/)) } end @@ -70,9 +87,7 @@ context 'when remote_development licensed feature is not enabled' do context 'when Web IDE Extension Marketplace feature is enabled' do - before do - allow(::WebIde::ExtensionMarketplace).to receive(:feature_enabled?).with(user: user).and_return(true) - end + let(:feature_enabled) { true } it { is_expected.to match(a_hash_including(title: 'Web IDE', message: /for the Web IDE/)) } end diff --git a/ee/spec/lib/ee/web_ide/settings/extension_marketplace_metadata_generator_spec.rb b/ee/spec/lib/ee/web_ide/settings/extension_marketplace_metadata_generator_spec.rb index 24be6d450b12a1feebf5b5082cddbbe8227e6957..46fa65abcae089c81e2bd4e06065b351745d26c5 100644 --- a/ee/spec/lib/ee/web_ide/settings/extension_marketplace_metadata_generator_spec.rb +++ b/ee/spec/lib/ee/web_ide/settings/extension_marketplace_metadata_generator_spec.rb @@ -5,7 +5,17 @@ RSpec.describe WebIde::Settings::ExtensionMarketplaceMetadataGenerator, feature_category: :web_ide do using RSpec::Parameterized::TableSyntax - let(:user_class) { stub_const('User', Class.new) } + let(:user_class) do + stub_const( + "User", + Class.new do + def flipper_id + "UserStub" + end + end + ) + end + let(:group_class) { stub_const('Namespace', Class.new) } let(:user) { user_class.new } let(:group) { group_class.new } @@ -43,6 +53,10 @@ extensions_marketplace_opt_in_status: :unset ) allow(group).to receive(:enterprise_users_extensions_marketplace_enabled?).and_return(enterprise_group_enabled) + + allow(::WebIde::ExtensionMarketplace).to receive(:feature_enabled_from_application_settings?) + .with(user: user) + .and_return(true) end it "adds settings with disabled reason based on enterprise_group presence and setting" do diff --git a/ee/spec/lib/web_ide/settings/settings_integration_spec.rb b/ee/spec/lib/web_ide/settings/settings_integration_spec.rb index 2fa6a9e107d1f6b8fae7d07afdf1f45b3b364ead..72219f8b1a12bc91d2cd324bd8625a5da37e1b01 100644 --- a/ee/spec/lib/web_ide/settings/settings_integration_spec.rb +++ b/ee/spec/lib/web_ide/settings/settings_integration_spec.rb @@ -15,8 +15,13 @@ subject(:settings) { described_class.get([:vscode_extension_marketplace_metadata], options) } before do - stub_feature_flags(vscode_web_ide: true, web_ide_extensions_marketplace: true) + stub_feature_flags( + vscode_web_ide: true, + web_ide_extensions_marketplace: true, + vscode_extension_marketplace_settings: true + ) stub_licensed_features(disable_extensions_marketplace_for_enterprise_users: true) + stub_application_setting(vscode_extension_marketplace: { enabled: true, preset: 'open_vsx' }) user.update!(extensions_marketplace_enabled: true) end diff --git a/ee/spec/requests/groups/dependencies_controller_spec.rb b/ee/spec/requests/groups/dependencies_controller_spec.rb index 9795af9b219eb3d37def1fdaaece1ebf1aefa5c0..c57a8f1bcb9aad4cd86397a40306c8e2210c2505 100644 --- a/ee/spec/requests/groups/dependencies_controller_spec.rb +++ b/ee/spec/requests/groups/dependencies_controller_spec.rb @@ -30,12 +30,6 @@ expect(response).to have_gitlab_http_status(:ok) end - it 'pushes group dependency list CSV export feature flag' do - subject - - expect(response.body).to have_pushed_frontend_feature_flags(groupDependencyListCsvExport: true) - end - it 'returns the correct template' do subject diff --git a/ee/spec/requests/groups_controller_spec.rb b/ee/spec/requests/groups_controller_spec.rb index 7f9b664a5faf075c77e9284c41efe2dcc1a65202..a019df293ab85f2ba11fca00e66fb0b293fbd15a 100644 --- a/ee/spec/requests/groups_controller_spec.rb +++ b/ee/spec/requests/groups_controller_spec.rb @@ -452,6 +452,7 @@ vscode_web_ide: true, web_ide_extensions_marketplace: true ) + stub_application_setting(vscode_extension_marketplace: { enabled: true, preset: 'open_vsx' }) end it 'does not change the column' do diff --git a/lib/web_ide/extension_marketplace.rb b/lib/web_ide/extension_marketplace.rb index 61cd2c63386f89d9e63c5cf70d607ad7782cbe1a..a2da3bb37316548917343359b0b095ac98e85ce6 100644 --- a/lib/web_ide/extension_marketplace.rb +++ b/lib/web_ide/extension_marketplace.rb @@ -2,28 +2,43 @@ module WebIde module ExtensionMarketplace - # This returns true if the extensions marketplace feature is available to any users + # Returns true if the extensions marketplace feature is enabled for any users # # @return [Boolean] def self.feature_enabled_for_any_user? - feature_flag_enabled_for_any_actor?(:web_ide_extensions_marketplace) && + # note: Intentionally pass `nil` here since we don't have a user in scope + feature_enabled_from_application_settings?(user: nil) && + feature_flag_enabled_for_any_actor?(:web_ide_extensions_marketplace) && feature_flag_enabled_for_any_actor?(:vscode_web_ide) end - # This returns true if the extensions marketplace feature is available to the given user + # Returns true if the extensions marketplace feature is enabled for the given user # # @param user [User] # @return [Boolean] def self.feature_enabled?(user:) - Feature.enabled?(:web_ide_extensions_marketplace, user) && - Feature.enabled?(:vscode_web_ide, user) + feature_enabled_from_application_settings?(user: user) && + feature_enabled_from_flags?(user: user) + end + + # Returns true if the ExtensionMarketplace feature is enabled from application settings + # + # @param user [User, nil] Current user for feature enablement context + # @return [Boolean] + def self.feature_enabled_from_application_settings?(user:) + return true unless should_use_application_settings?(user: user) + + Gitlab::CurrentSettings.vscode_extension_marketplace&.fetch('enabled', false) end # This value is used when the end-user is accepting the third-party extension marketplace integration. # + # @param user [User] Current user for context # @return [String] URL of the VSCode Extension Marketplace home - def self.marketplace_home_url - "https://open-vsx.org" + def self.marketplace_home_url(user:) + Gitlab::SafeRequestStore.fetch(:vscode_extension_marketplace_home_url) do + Settings.get_single_setting(:vscode_extension_marketplace_home_url, user: user) + end end # @return [String] URL of the help page for the user preferences for Extensions Marketplace opt-in @@ -43,7 +58,7 @@ def self.webide_extension_marketplace_settings(user:) Settings.get_single_setting( :vscode_extension_marketplace_view_model, user: user, - vscode_extension_marketplace_feature_flag_enabled: feature_enabled?(user: user) + vscode_extension_marketplace_feature_flag_enabled: feature_enabled_from_flags?(user: user) ) end @@ -61,6 +76,28 @@ def self.feature_flag_enabled_for_any_actor?(flag) feature && !feature.off? end - private_class_method :feature_flag_enabled_for_any_actor? + # Returns true if we should use `feature_enabled_from_application_settings?` to determine feature availability + # + # @param user [User, nil] Current user for feature enablement context + # @return [Boolean] + def self.should_use_application_settings?(user:) + if user + Feature.enabled?(:vscode_extension_marketplace_settings, user) + else + feature_flag_enabled_for_any_actor?(:vscode_extension_marketplace_settings) + end + end + + # This returns true if the extensions marketplace flags are enabled + # + # @param user [User] + # @return [Boolean] + def self.feature_enabled_from_flags?(user:) + Feature.enabled?(:web_ide_extensions_marketplace, user) && + Feature.enabled?(:vscode_web_ide, user) + end + + private_class_method :feature_flag_enabled_for_any_actor?, :should_use_application_settings?, + :feature_enabled_from_flags? end end diff --git a/lib/web_ide/extension_marketplace_preset.rb b/lib/web_ide/extension_marketplace_preset.rb new file mode 100644 index 0000000000000000000000000000000000000000..0a42371669a61012018f161d2455992231e17006 --- /dev/null +++ b/lib/web_ide/extension_marketplace_preset.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module WebIde + class ExtensionMarketplacePreset + CUSTOM_KEY = "custom" + + def self.all + [open_vsx] + end + + def self.open_vsx + # note: This is effectively a constant so lets memoize + @open_vsx ||= new( + "open_vsx", + "Open VSX", + # See https://open-vsx.org/swagger-ui/index.html?urls.primaryName=VSCode%20Adapter for OpenVSX Swagger API + service_url: "https://open-vsx.org/vscode/gallery", + item_url: "https://open-vsx.org/vscode/item", + resource_url_template: "https://open-vsx.org/vscode/unpkg/{publisher}/{name}/{version}/{path}" + ) + end + + def initialize(key, name, service_url:, item_url:, resource_url_template:) + @key = key + @name = name + @values = { + service_url: service_url, + item_url: item_url, + resource_url_template: resource_url_template + }.freeze + end + + attr_reader :key, :name, :values + + def to_h + { + key: key, + name: name, + values: values + } + end + end +end diff --git a/lib/web_ide/settings/default_settings.rb b/lib/web_ide/settings/default_settings.rb index 19df3da3b065c27117a0704bed0af2b4f4b8def5..20a690034d4236ebae91ba4b71b9edee2e618934 100644 --- a/lib/web_ide/settings/default_settings.rb +++ b/lib/web_ide/settings/default_settings.rb @@ -8,24 +8,17 @@ class DefaultSettings def self.default_settings { vscode_extension_marketplace: [ - # See https://sourcegraph.com/github.com/microsoft/vscode@6979fb003bfa575848eda2d3966e872a9615427b/-/blob/src/vs/base/common/product.ts?L96 - # for the original source of settings entries in the VS Code source code. - # See https://open-vsx.org/swagger-ui/index.html?urls.primaryName=VSCode%20Adapter# - # for OpenVSX Swagger API - { - service_url: "https://open-vsx.org/vscode/gallery", - item_url: "https://open-vsx.org/vscode/item", - resource_url_template: "https://open-vsx.org/vscode/unpkg/{publisher}/{name}/{version}/{path}", - control_url: "", - nls_base_url: "", - publisher_url: "" - }, + {}, # NOTE: There is no default, the value is always generated by ExtensionMarketplaceGenerator Hash ], vscode_extension_marketplace_metadata: [ { enabled: false, disabled_reason: :instance_disabled }, Hash ], + vscode_extension_marketplace_home_url: [ + "", + String + ], vscode_extension_marketplace_view_model: [ { enabled: false, reason: :instance_disabled, help_url: '' }, Hash diff --git a/lib/web_ide/settings/extension_marketplace_generator.rb b/lib/web_ide/settings/extension_marketplace_generator.rb new file mode 100644 index 0000000000000000000000000000000000000000..47de4e9a054688c3a08c214082b05fca2d80d6fc --- /dev/null +++ b/lib/web_ide/settings/extension_marketplace_generator.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module WebIde + module Settings + class ExtensionMarketplaceGenerator + # @param [Hash] context + # @return [Hash] + def self.generate(context) + return context unless context.fetch(:requested_setting_names).include?(:vscode_extension_marketplace) + + user = context.dig(:options, :user) + + context[:settings][:vscode_extension_marketplace] = extension_marketplace_from_application_settings(user) + context + end + + # @param [User, nil] user + # @return [Hash] + def self.extension_marketplace_from_application_settings(user) + unless Feature.enabled?(:vscode_extension_marketplace_settings, user) + return ::WebIde::ExtensionMarketplacePreset.open_vsx.values + end + + settings = Gitlab::CurrentSettings.vscode_extension_marketplace + preset_key = settings.fetch("preset", ::WebIde::ExtensionMarketplacePreset.open_vsx.key) + + if preset_key == ::WebIde::ExtensionMarketplacePreset::CUSTOM_KEY + settings.fetch("custom_values").deep_symbolize_keys + else + preset = ::WebIde::ExtensionMarketplacePreset.all.find { |x| x.key == preset_key } + preset&.values + end + end + + private_class_method :extension_marketplace_from_application_settings + end + end +end diff --git a/lib/web_ide/settings/extension_marketplace_home_url_generator.rb b/lib/web_ide/settings/extension_marketplace_home_url_generator.rb new file mode 100644 index 0000000000000000000000000000000000000000..d691e0477fd07df4f5e5e56af24299ab4e42d981 --- /dev/null +++ b/lib/web_ide/settings/extension_marketplace_home_url_generator.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module WebIde + module Settings + class ExtensionMarketplaceHomeUrlGenerator + # @param [Hash] context + # @return [Hash] + def self.generate(context) + return context unless context.fetch(:requested_setting_names).include?(:vscode_extension_marketplace_home_url) + + context[:settings][:vscode_extension_marketplace_home_url] = home_url(context) + context + end + + # @param [Hash] context + # @return [String] The URL to use for the extension marketplace home + def self.home_url(context) + context => { + settings: { + vscode_extension_marketplace: Hash => vscode_settings, + } + } + + item_url = vscode_settings&.fetch(:item_url, nil) + + return "" unless item_url + + base_url = ::Gitlab::UrlHelpers.normalized_base_url(item_url) + + # NOTE: It's possible for `normalized_base_url` to return something like `://` so let's go ahead and check + # that we actually start with `http` or `https`. + return base_url if /^https?:/.match?(base_url) + + "" + end + + private_class_method :home_url + end + end +end diff --git a/lib/web_ide/settings/extension_marketplace_metadata_generator.rb b/lib/web_ide/settings/extension_marketplace_metadata_generator.rb index af71de6b0c88a0e61ac61f8a8a0cb47442dea1aa..40fe374c4129f70303868fe58b7afc5bd671e273 100644 --- a/lib/web_ide/settings/extension_marketplace_metadata_generator.rb +++ b/lib/web_ide/settings/extension_marketplace_metadata_generator.rb @@ -48,6 +48,10 @@ def self.build_metadata(user:, flag_enabled:) return metadata_disabled(:no_flag) if flag_enabled.nil? return metadata_disabled(:instance_disabled) unless flag_enabled + unless ::WebIde::ExtensionMarketplace.feature_enabled_from_application_settings?(user: user) + return metadata_disabled(:instance_disabled) + end + build_metadata_for_user(user) end diff --git a/lib/web_ide/settings/main.rb b/lib/web_ide/settings/main.rb index 49fb2656f804a89a3175c1184864fe6906deda34..e845fd02ecbcb4e22e2011d5dde75621fe6bf365 100644 --- a/lib/web_ide/settings/main.rb +++ b/lib/web_ide/settings/main.rb @@ -17,6 +17,8 @@ def self.get_settings(context) result = initial_result .map(SettingsInitializer.method(:init)) + .map(ExtensionMarketplaceGenerator.method(:generate)) + .map(ExtensionMarketplaceHomeUrlGenerator.method(:generate)) .map(ExtensionMarketplaceMetadataGenerator.method(:generate)) # NOTE: EnvVarOverrideProcessor is inserted here to easily override settings for local or temporary testing # it should happen **before** validators. diff --git a/lib/web_ide/settings/settings_initializer.rb b/lib/web_ide/settings/settings_initializer.rb index ec29038f4aac94420cc903d6899b16b9c5f75f5a..26ae8b2e63fe04da936a18b35b4ff20d6c5aa6be 100644 --- a/lib/web_ide/settings/settings_initializer.rb +++ b/lib/web_ide/settings/settings_initializer.rb @@ -7,6 +7,9 @@ class SettingsInitializer vscode_extension_marketplace_view_model: [ :vscode_extension_marketplace_metadata, :vscode_extension_marketplace + ], + vscode_extension_marketplace_home_url: [ + :vscode_extension_marketplace ] }.freeze diff --git a/package.json b/package.json index ccfc5b2260a4a7a861e93f87c512797ddd057e6d..2f74fe391e4761cb6d40e6a6d3128fe79ea68846 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "@gitlab/duo-ui": "^8.4.0", "@gitlab/favicon-overlay": "2.0.0", "@gitlab/fonts": "^1.3.0", - "@gitlab/query-language-rust": "0.4.0", + "@gitlab/query-language-rust": "0.4.2", "@gitlab/svgs": "3.123.0", "@gitlab/ui": "110.1.0", "@gitlab/vue-router-vue3": "npm:vue-router@4.5.0", diff --git a/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_spec.js b/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_spec.js index 7893a75b5b4228ff11f5f5c9348fc0dd1ae38081..485939f4e44eae079709cc1f71bfa0762dcf6a80 100644 --- a/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_spec.js +++ b/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_spec.js @@ -2,8 +2,13 @@ import { GlForm, GlCollapsibleListbox, GlLoadingIcon } from '@gitlab/ui'; import { nextTick } from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { groupIterationsResponse } from 'jest/work_items/mock_data'; +import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle'; +import { keysFor } from '~/behaviors/shortcuts/keybindings'; import WorkItemSidebarDropdownWidget from '~/work_items/components/shared/work_item_sidebar_dropdown_widget.vue'; +jest.mock('~/behaviors/shortcuts/shortcuts_toggle'); +jest.mock('~/behaviors/shortcuts/keybindings'); + describe('WorkItemSidebarDropdownWidget component', () => { let wrapper; @@ -28,6 +33,7 @@ describe('WorkItemSidebarDropdownWidget component', () => { infiniteScrollLoading = false, clearSearchOnItemSelect = false, listItems = [], + shortcut = undefined, } = {}) => { wrapper = mountExtended(WorkItemSidebarDropdownWidget, { propsData: { @@ -43,6 +49,7 @@ describe('WorkItemSidebarDropdownWidget component', () => { infiniteScroll, infiniteScrollLoading, clearSearchOnItemSelect, + shortcut, }, slots, }); @@ -257,4 +264,47 @@ describe('WorkItemSidebarDropdownWidget component', () => { }); }); }); + describe('shortcut tooltip', () => { + const shortcut = { + description: 'Edit dropdown', + }; + + beforeEach(() => { + shouldDisableShortcuts.mockReturnValue(false); + keysFor.mockReturnValue(['e']); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('shows tooltip with key when shortcut is provided', () => { + createComponent({ + canUpdate: true, + shortcut, + }); + const expectedTooltip = 'Edit dropdown <kbd aria-hidden="true" class="flat gl-ml-1">e</kbd>'; + + expect(findEditButton().attributes('title')).toContain(expectedTooltip); + }); + + it('does not show tooltip when shortcut is not provided', () => { + createComponent({ + canUpdate: true, + }); + + expect(findEditButton().attributes('title')).toBeUndefined(); + }); + + it('does not show tooltip when shortcuts are disabled', () => { + shouldDisableShortcuts.mockReturnValue(true); + + createComponent({ + canUpdate: true, + shortcut, + }); + + expect(findEditButton().attributes('title')).toBeUndefined(); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js index aba02393fd0c6ec755027066de707335fff764d7..b74f6f810db901f329bc1428a4c8aecd6d3380d8 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -26,6 +26,7 @@ import { workItemResponseFactory, } from 'jest/work_items/mock_data'; import { i18n, TRACKING_CATEGORY_SHOW, NEW_WORK_ITEM_IID } from '~/work_items/constants'; +import { ISSUE_MR_CHANGE_ASSIGNEE } from '~/behaviors/shortcuts/keybindings'; describe('WorkItemAssignees component', () => { Vue.use(VueApollo); @@ -126,6 +127,12 @@ describe('WorkItemAssignees component', () => { expect(findSidebarDropdownWidget().props('dropdownLabel')).toBe('Assignee'); }); + it('has key shortcut tooltip', () => { + createComponent(); + + expect(findSidebarDropdownWidget().props('shortcut')).toBe(ISSUE_MR_CHANGE_ASSIGNEE); + }); + describe('Dropdown search', () => { it('shows no items in the dropdown when no results matching', async () => { createComponent({ searchQueryHandler: successSearchWithNoMatchingUsers }); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index b393932ebfd571e685a2f8ed61a9b7a1491f5760..68801489953ff21c945ae2debd50ded1b405f1ca 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -1141,6 +1141,7 @@ describe('WorkItemDetail component', () => { it('shows the edit button', () => { expect(findEditButton().exists()).toBe(true); + expect(findEditButton().attributes('title')).toContain('Edit title and description'); }); it('renders the work item title with edit component', () => { diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js index 4e36531bc061d98f7a3d65e2102ea95caee57894..e389637cd500ea9b2e8fb2a6cff010801ec18c9a 100644 --- a/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -13,6 +13,7 @@ import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutati import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import WorkItemSidebarDropdownWidget from '~/work_items/components/shared/work_item_sidebar_dropdown_widget.vue'; +import { ISSUABLE_CHANGE_LABEL } from '~/behaviors/shortcuts/keybindings'; import { projectLabelsResponse, groupLabelsResponse, @@ -145,6 +146,7 @@ describe('WorkItemLabels component', () => { multiSelect: true, showFooter: true, itemValue: [], + shortcut: ISSUABLE_CHANGE_LABEL, }); expect(findAllLabels()).toHaveLength(0); }); diff --git a/spec/frontend/work_items/components/work_item_milestone_spec.js b/spec/frontend/work_items/components/work_item_milestone_spec.js index 3e361f344d80d42503f32d34f96d7964bcef03a5..00b3fd8ad2c44aba9d6399183517120db00e3c5e 100644 --- a/spec/frontend/work_items/components/work_item_milestone_spec.js +++ b/spec/frontend/work_items/components/work_item_milestone_spec.js @@ -10,6 +10,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; +import { ISSUE_MR_CHANGE_MILESTONE } from '~/behaviors/shortcuts/keybindings'; import { projectMilestonesResponse, projectMilestonesResponseWithNoMilestones, @@ -68,6 +69,12 @@ describe('WorkItemMilestone component', () => { expect(findSidebarDropdownWidget().props('dropdownLabel')).toBe('Milestone'); }); + it('has key shortcut tooltip', () => { + createComponent(); + + expect(findSidebarDropdownWidget().props('shortcut')).toBe(ISSUE_MR_CHANGE_MILESTONE); + }); + describe('Default text with canUpdate false and milestone value', () => { describe.each` description | milestone | value diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb index 7c9e62c8e66cb17ac440b8e916c2dae04dbbeff8..acc24554db9c47bfb7526b2d890b997a5aea21cb 100644 --- a/spec/helpers/preferences_helper_spec.rb +++ b/spec/helpers/preferences_helper_spec.rb @@ -268,7 +268,13 @@ def stub_user(messages = {}) context 'when Web IDE Extension Marketplace feature is enabled' do before do - allow(::WebIde::ExtensionMarketplace).to receive(:feature_enabled?).with(user: user).and_return(true) + allow(::WebIde::ExtensionMarketplace).to receive(:feature_enabled_from_application_settings?) + .with(user: user) + .and_return(true) + + allow(::WebIde::ExtensionMarketplace).to receive(:feature_enabled?) + .with(user: user) + .and_return(true) end it 'includes extension marketplace integration' do diff --git a/spec/lib/web_ide/extension_marketplace_preset_spec.rb b/spec/lib/web_ide/extension_marketplace_preset_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a1d85b86f8213732c54bad9e6aa4a8d42dab9a8d --- /dev/null +++ b/spec/lib/web_ide/extension_marketplace_preset_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe WebIde::ExtensionMarketplacePreset, feature_category: :web_ide do + describe '.all' do + subject(:all) { described_class.all } + + it { is_expected.to eq([described_class.open_vsx]) } + end + + describe '.open_vsx' do + subject(:open_vsx) { described_class.open_vsx } + + it "has OpenVSX properties" do + is_expected.to have_attributes( + key: 'open_vsx', + name: "Open VSX", + values: { + service_url: "https://open-vsx.org/vscode/gallery", + item_url: "https://open-vsx.org/vscode/item", + resource_url_template: "https://open-vsx.org/vscode/unpkg/{publisher}/{name}/{version}/{path}" + } + ) + end + end + + describe '#to_h' do + it 'returns hash of attributes' do + preset = described_class.new( + "test_key", + "Test Key", + service_url: "abc", + item_url: "def", + resource_url_template: "ghi" + ) + + expect(preset.to_h).to eq({ + key: "test_key", + name: "Test Key", + values: { + service_url: "abc", + item_url: "def", + resource_url_template: "ghi" + } + }) + end + end +end diff --git a/spec/lib/web_ide/extension_marketplace_spec.rb b/spec/lib/web_ide/extension_marketplace_spec.rb index 8f85e9090d99f7ec8d2c3560b3d09b07756fe9af..c3a001a8332247cb4bee66bb2f92b97d2eef8918 100644 --- a/spec/lib/web_ide/extension_marketplace_spec.rb +++ b/spec/lib/web_ide/extension_marketplace_spec.rb @@ -7,30 +7,41 @@ let(:help_url) { "/help/user/project/web_ide/_index.md#extension-marketplace" } let(:user_preferences_url) { "/-/profile/preferences#integrations" } - - let_it_be_with_reload(:current_user) { create(:user) } - let_it_be(:default_vscode_settings) do + let(:custom_app_setting) do { - item_url: 'https://open-vsx.org/vscode/item', - service_url: 'https://open-vsx.org/vscode/gallery', - resource_url_template: - 'https://open-vsx.org/vscode/unpkg/{publisher}/{name}/{version}/{path}' + enabled: true, + preset: "custom", + custom_values: { + item_url: "https://example.com:8444/vscode/item", + service_url: "https://example.com:8444/vscode/service", + resource_url_template: "https://example.com:8444/vscode/resource" + } } end + let(:open_vsx_app_setting) { custom_app_setting.merge(preset: 'open_vsx') } + + let_it_be_with_reload(:current_user) { create(:user) } + describe 'feature enabled methods' do - where(:vscode_web_ide, :web_ide_extensions_marketplace, :expectation) do - ref(:current_user) | ref(:current_user) | true - ref(:current_user) | false | false - false | ref(:current_user) | false + where(:vscode_web_ide, :web_ide_extensions_marketplace, :vscode_extension_marketplace_settings, :app_setting, + :expectation) do + ref(:current_user) | ref(:current_user) | false | {} | true + ref(:current_user) | ref(:current_user) | true | {} | false + ref(:current_user) | ref(:current_user) | true | { enabled: true } | true + ref(:current_user) | false | false | { enabled: true } | false + false | ref(:current_user) | false | {} | false end with_them do before do stub_feature_flags( vscode_web_ide: vscode_web_ide, - web_ide_extensions_marketplace: web_ide_extensions_marketplace + web_ide_extensions_marketplace: web_ide_extensions_marketplace, + vscode_extension_marketplace_settings: vscode_extension_marketplace_settings ) + + stub_application_setting(vscode_extension_marketplace: app_setting) end describe '#feature_enabled?' do @@ -43,75 +54,89 @@ end end - describe '#marketplace_home_url' do - it { expect(described_class.marketplace_home_url).to eq('https://open-vsx.org') } - end + describe '#feature_enabled_from_application_settings?' do + where(:vscode_extension_marketplace_settings, :app_setting, :user_arg, :expectation) do + false | {} | ref(:current_user) | true + false | { enabled: true } | ref(:current_user) | true + false | { enabled: true } | nil | true + ref(:current_user) | { enabled: true } | nil | true + ref(:current_user) | { enabled: true } | ref(:current_user) | true + ref(:current_user) | {} | ref(:current_user) | false + ref(:current_user) | { enabled: false } | ref(:current_user) | false + end + + subject(:enabled) do + described_class.feature_enabled_from_application_settings?(user: user_arg) + end - describe '#help_url' do - it { expect(help_url).to match('/help/user/project/web_ide/_index.md#extension-marketplace') } + with_them do + before do + stub_feature_flags(vscode_extension_marketplace_settings: vscode_extension_marketplace_settings) + stub_application_setting(vscode_extension_marketplace: app_setting) + end + + it { is_expected.to be(expectation) } + end end - describe '#help_preferences_url' do - it 'returns expected url' do - expect(described_class.help_preferences_url).to match( - '/help/user/profile/preferences.md#integrate-with-the-extension-marketplace' - ) + describe '#marketplace_home_url' do + where(:vscode_extension_marketplace_settings, :app_setting, :expectation) do + false | {} | "https://open-vsx.org" + true | {} | "https://open-vsx.org" + true | ref(:custom_app_setting) | "https://example.com:8444" + true | ref(:open_vsx_app_setting) | "https://open-vsx.org" + end + + subject(:marketplace_home_url) do + described_class.marketplace_home_url(user: current_user) + end + + with_them do + before do + stub_feature_flags(vscode_extension_marketplace_settings: vscode_extension_marketplace_settings) + stub_application_setting(vscode_extension_marketplace: app_setting) + end + + it { is_expected.to eq(expectation) } end end - describe '#user_preferences_url' do - it { expect(user_preferences_url).to match('/-/profile/preferences#integrations') } + describe '#help_preferences_url' do + subject(:url) { described_class.help_preferences_url } + + it { is_expected.to match('/help/user/profile/preferences.md#integrate-with-the-extension-marketplace') } end describe '#webide_extension_marketplace_settings' do - subject(:webide_settings) { described_class.webide_extension_marketplace_settings(user: current_user) } + # rubocop:disable Layout/LineLength -- last parameter extens past line but is preferable to rubocop's suggestion + where(:web_ide_extensions_marketplace, :vscode_extension_marketplace_settings, :app_setting, :opt_in_status, :expectation) do + true | false | {} | :enabled | lazy { { enabled: true, vscode_settings: ::WebIde::ExtensionMarketplacePreset.open_vsx.values } } + true | false | {} | :unset | lazy { { enabled: false, reason: :opt_in_unset, help_url: /#{help_url}/, user_preferences_url: /#{user_preferences_url}/ } } + true | false | {} | :disabled | lazy { { enabled: false, reason: :opt_in_disabled, help_url: /#{help_url}/, user_preferences_url: /#{user_preferences_url}/ } } + false | false | {} | :enabled | lazy { { enabled: false, reason: :instance_disabled, help_url: /#{help_url}/ } } + true | true | {} | :enabled | lazy { { enabled: false, reason: :instance_disabled, help_url: /#{help_url}/ } } + true | true | { enabled: false } | :enabled | lazy { { enabled: false, reason: :instance_disabled, help_url: /#{help_url}/ } } + true | true | ref(:custom_app_setting) | :enabled | lazy { { enabled: true, vscode_settings: custom_app_setting[:custom_values] } } + true | true | ref(:open_vsx_app_setting) | :enabled | lazy { { enabled: true, vscode_settings: ::WebIde::ExtensionMarketplacePreset.open_vsx.values } } + end + # rubocop:enable Layout/LineLength - context 'when instance enabled' do - before do - stub_feature_flags( - web_ide_extensions_marketplace: current_user, - vscode_web_ide: current_user - ) - end + subject(:webide_settings) { described_class.webide_extension_marketplace_settings(user: current_user) } - it 'when user opt in enabled, returns enabled settings' do - current_user.update!(extensions_marketplace_opt_in_status: :enabled) + before do + stub_feature_flags( + vscode_extension_marketplace_settings: vscode_extension_marketplace_settings, + web_ide_extensions_marketplace: web_ide_extensions_marketplace, + vscode_web_ide: true + ) - expect(webide_settings).to match({ - enabled: true, - vscode_settings: hash_including(default_vscode_settings) - }) - end + stub_application_setting(vscode_extension_marketplace: app_setting) - context 'when user opt in disabled' do - where(:opt_in_status, :reason) do - :unset | :opt_in_unset - :disabled | :opt_in_disabled - end - - with_them do - it 'returns disabled settings' do - current_user.update!(extensions_marketplace_opt_in_status: opt_in_status) - - expect(webide_settings).to match({ - enabled: false, - reason: reason, - help_url: /#{help_url}/, - user_preferences_url: /#{user_preferences_url}/ - }) - end - end - end + current_user.update!(extensions_marketplace_opt_in_status: opt_in_status) end - context 'when instance disabled' do - it 'returns disabled settings and help url' do - expect(webide_settings).to match({ - enabled: false, - reason: :instance_disabled, - help_url: /#{help_url}/ - }) - end + with_them do + it { is_expected.to match(expectation) } end end end diff --git a/spec/lib/web_ide/settings/extension_marketplace_generator_spec.rb b/spec/lib/web_ide/settings/extension_marketplace_generator_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..891400204966fe71a0a08a39292ef4bf8a8ec870 --- /dev/null +++ b/spec/lib/web_ide/settings/extension_marketplace_generator_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "fast_spec_helper" + +RSpec.describe WebIde::Settings::ExtensionMarketplaceGenerator, feature_category: :web_ide do + using RSpec::Parameterized::TableSyntax + + let(:user_class) do + stub_const( + "User", + Class.new do + def flipper_id + "UserStub" + end + end + ) + end + + let(:user) { user_class.new } + let(:requested_setting_names) { [:vscode_extension_marketplace] } + let(:custom_app_settings) do + { + "enabled" => false, + "preset" => "custom", + "custom_values" => { + "item_url" => "abc", + "service_url" => "def", + "resource_url_template" => "ghi" + } + } + end + + let(:context) do + { + requested_setting_names: requested_setting_names, + options: { user: user }, + settings: {} + } + end + + subject(:result) { described_class.generate(context)[:settings][:vscode_extension_marketplace] } + + before do + allow(Feature).to receive(:enabled?).with(:vscode_extension_marketplace_settings, user).and_return(settings_flag) + allow(Gitlab::CurrentSettings).to receive(:vscode_extension_marketplace).and_return(app_setting) + end + + describe 'default (with setting requested)' do + where(:settings_flag, :app_setting, :expectation) do + false | {} | ::WebIde::ExtensionMarketplacePreset.open_vsx.values + true | {} | ::WebIde::ExtensionMarketplacePreset.open_vsx.values + true | { "preset" => 'open_vsx' } | ::WebIde::ExtensionMarketplacePreset.open_vsx.values + true | ref(:custom_app_settings) | { item_url: "abc", service_url: "def", resource_url_template: "ghi" } + # This should never happen, but lets test it anyways + true | { "preset" => 'DNE' } | nil + end + + with_them do + it { is_expected.to eq(expectation) } + end + end + + describe 'without setting requested' do + let(:requested_setting_names) { [] } + let(:settings_flag) { true } + let(:app_setting) { custom_app_settings } + + it { is_expected.to be_nil } + end +end diff --git a/spec/lib/web_ide/settings/extension_marketplace_home_url_generator_spec.rb b/spec/lib/web_ide/settings/extension_marketplace_home_url_generator_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..357515e289f61161559a7c09529d74f17a2a780b --- /dev/null +++ b/spec/lib/web_ide/settings/extension_marketplace_home_url_generator_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "fast_spec_helper" + +RSpec.describe WebIde::Settings::ExtensionMarketplaceHomeUrlGenerator, feature_category: :web_ide do + using RSpec::Parameterized::TableSyntax + + let(:vscode_extension_marketplace) { {} } + let(:requested_setting_names) { [:vscode_extension_marketplace_home_url] } + let(:context) do + { + requested_setting_names: requested_setting_names, + settings: { + vscode_extension_marketplace: vscode_extension_marketplace + } + } + end + + subject(:result) { described_class.generate(context)[:settings][:vscode_extension_marketplace_home_url] } + + where(:requested_setting_names, :vscode_extension_marketplace, :expectation) do + [:vscode_extension_marketplace_home_url] | {} | '' + [:vscode_extension_marketplace_home_url] | { item_url: 'https://example.com/foo/bar' } | 'https://example.com' + [:vscode_extension_marketplace_home_url] | { item_url: 'https://example.com:123/foo' } | 'https://example.com:123' + [:vscode_extension_marketplace_home_url] | { item_url: 'not really a thing...' } | '' + [] | { item_url: 'https://example.com:123/foo' } | nil + end + + with_them do + it { is_expected.to eq(expectation) } + end +end diff --git a/spec/lib/web_ide/settings/extension_marketplace_metadata_generator_spec.rb b/spec/lib/web_ide/settings/extension_marketplace_metadata_generator_spec.rb index 1e3f42e267e42dd7eef855b2e94b51156b3b9736..6f768dce9f5b221e2e3e423831491452be9036d3 100644 --- a/spec/lib/web_ide/settings/extension_marketplace_metadata_generator_spec.rb +++ b/spec/lib/web_ide/settings/extension_marketplace_metadata_generator_spec.rb @@ -46,25 +46,39 @@ :opt_in_status, :flag_exists, :flag_enabled, + :app_settings_enabled, :expected_vscode_extension_marketplace_metadata ) do # @formatter:off - Turn off RubyMine autoformatting - # user exists | opt_in_status | flag exists | flag_enabled | expected_settings - false | :undefined | false | :undefined | { enabled: false, disabled_reason: :no_user } - false | :undefined | true | true | { enabled: false, disabled_reason: :no_user } - true | :unset | false | :undefined | { enabled: false, disabled_reason: :no_flag } - true | :unset | true | false | { enabled: false, disabled_reason: :instance_disabled } - true | :unset | true | true | { enabled: false, disabled_reason: :opt_in_unset } - true | :disabled | true | true | { enabled: false, disabled_reason: :opt_in_disabled } - true | :enabled | true | true | { enabled: true } - true | :invalid | true | true | RuntimeError + # rubocop:disable Layout/LineLength -- Parameterized rows overflow and its better than the alternative + # user exists | opt_in_status | flag exists | flag_enabled | app_settings_enabled | expected_settings + false | :undefined | false | :undefined | true | { enabled: false, disabled_reason: :no_user } + false | :undefined | true | true | true | { enabled: false, disabled_reason: :no_user } + true | :unset | false | :undefined | true | { enabled: false, disabled_reason: :no_flag } + true | :unset | true | false | true | { enabled: false, disabled_reason: :instance_disabled } + true | :unset | true | true | true | { enabled: false, disabled_reason: :opt_in_unset } + true | :disabled | true | true | true | { enabled: false, disabled_reason: :opt_in_disabled } + true | :enabled | true | true | false | { enabled: false, disabled_reason: :instance_disabled } + true | :enabled | true | true | true | { enabled: true } + true | :invalid | true | true | true | RuntimeError + # rubocop:enable Layout/LineLength # @formatter:on end with_them do - let(:user_class) { stub_const('User', Class.new) } + let(:user_class) do + stub_const( + "User", + Class.new do + def flipper_id + "UserStub" + end + end + ) + end + let(:user) { user_class.new } let(:enums) { stub_const('Enums::WebIde::ExtensionsMarketplaceOptInStatus', Class.new) } @@ -80,6 +94,9 @@ # EE feature has to be stubbed since we run EE code through CE tests allow(user).to receive(:enterprise_user?).and_return(false) allow(enums).to receive(:statuses).and_return({ unset: :unset, enabled: :enabled, disabled: :disabled }) + allow(::WebIde::ExtensionMarketplace).to receive(:feature_enabled_from_application_settings?) + .with(user: user) + .and_return(app_settings_enabled) end it_behaves_like "extension marketplace settings" diff --git a/spec/lib/web_ide/settings/extension_marketplace_validator_spec.rb b/spec/lib/web_ide/settings/extension_marketplace_validator_spec.rb index 38c2dfc051db5cc9ff715ee16154841b85aa5f2d..8fa1e69b0011fd42f9feb0a648cf8d9b4ce6ca6a 100644 --- a/spec/lib/web_ide/settings/extension_marketplace_validator_spec.rb +++ b/spec/lib/web_ide/settings/extension_marketplace_validator_spec.rb @@ -12,10 +12,7 @@ { service_url: service_url, item_url: item_url, - resource_url_template: resource_url_template, - control_url: "", - nls_base_url: "", - publisher_url: "" + resource_url_template: resource_url_template } end diff --git a/spec/lib/web_ide/settings/main_spec.rb b/spec/lib/web_ide/settings/main_spec.rb index 3629c4b71095235405ec4287d78708ec0e8821d3..613232c27340327461ba61b7be8b04d73e6a6177 100644 --- a/spec/lib/web_ide/settings/main_spec.rb +++ b/spec/lib/web_ide/settings/main_spec.rb @@ -9,6 +9,8 @@ let(:rop_steps) do [ [WebIde::Settings::SettingsInitializer, :map], + [WebIde::Settings::ExtensionMarketplaceGenerator, :map], + [WebIde::Settings::ExtensionMarketplaceHomeUrlGenerator, :map], [WebIde::Settings::ExtensionMarketplaceMetadataGenerator, :map], [Gitlab::Fp::Settings::EnvVarOverrideProcessor, :and_then], [WebIde::Settings::ExtensionMarketplaceValidator, :and_then], diff --git a/spec/lib/web_ide/settings/settings_initializer_spec.rb b/spec/lib/web_ide/settings/settings_initializer_spec.rb index e22a16f55a4abeec023c3d9a0a72b13370832a65..468b1138bbf48523ec3228b594434f06684f7752 100644 --- a/spec/lib/web_ide/settings/settings_initializer_spec.rb +++ b/spec/lib/web_ide/settings/settings_initializer_spec.rb @@ -18,17 +18,12 @@ requested_setting_names: [ :vscode_extension_marketplace, :vscode_extension_marketplace_metadata, + :vscode_extension_marketplace_home_url, :vscode_extension_marketplace_view_model ], settings: { - vscode_extension_marketplace: { - control_url: "", - item_url: "https://open-vsx.org/vscode/item", - nls_base_url: "", - publisher_url: "", - resource_url_template: 'https://open-vsx.org/vscode/unpkg/{publisher}/{name}/{version}/{path}', - service_url: "https://open-vsx.org/vscode/gallery" - }, + vscode_extension_marketplace: {}, + vscode_extension_marketplace_home_url: "", vscode_extension_marketplace_metadata: { enabled: false, disabled_reason: :instance_disabled @@ -41,6 +36,7 @@ }, setting_types: { vscode_extension_marketplace: Hash, + vscode_extension_marketplace_home_url: String, vscode_extension_marketplace_metadata: Hash, vscode_extension_marketplace_view_model: Hash }, diff --git a/spec/lib/web_ide/settings/settings_integration_spec.rb b/spec/lib/web_ide/settings/settings_integration_spec.rb index 532d9ceb0b4420fe7c5703e0b6ba9a6a24319478..ddf8c8f526f427d5f7f097c0c44759ccb759c184 100644 --- a/spec/lib/web_ide/settings/settings_integration_spec.rb +++ b/spec/lib/web_ide/settings/settings_integration_spec.rb @@ -9,10 +9,7 @@ { service_url: "https://open-vsx.org/vscode/gallery", item_url: "https://open-vsx.org/vscode/item", - resource_url_template: 'https://open-vsx.org/vscode/unpkg/{publisher}/{name}/{version}/{path}', - control_url: "", - nls_base_url: "", - publisher_url: "" + resource_url_template: 'https://open-vsx.org/vscode/unpkg/{publisher}/{name}/{version}/{path}' } end @@ -103,10 +100,7 @@ expected_value = { item_url: "https://open-vsx.org/vscode/item", resource_url_template: "https://open-vsx.org/vscode/unpkg/{publisher}/{name}/{version}/{path}", - service_url: "https://open-vsx.org/vscode/gallery", - control_url: "", - nls_base_url: "", - publisher_url: "" + service_url: "https://open-vsx.org/vscode/gallery" } expect(vscode_extension_marketplace_setting).to eq(expected_value) diff --git a/spec/models/hooks/web_hook_log_spec.rb b/spec/models/hooks/web_hook_log_spec.rb index 9b1fde71a6aa687c41930e072e2506fef365de5a..4c928cb0e3f1408cbb5ab28f6d963b6892b3fe8a 100644 --- a/spec/models/hooks/web_hook_log_spec.rb +++ b/spec/models/hooks/web_hook_log_spec.rb @@ -292,10 +292,37 @@ end describe 'routing table switch' do + shared_examples 'both tables are set up for partitioning' do + let(:tables_registered_for_sync) { Gitlab::Database::Partitioning.send(:registered_for_sync) } + it 'registers both web_hook_logs and web_hook_logs_daily for partitioning' do + expect(tables_registered_for_sync.map(&:table_name)).to include('web_hook_logs_daily', 'web_hook_logs') + end + + it 'registers web_hook_logs for monthly partitioning' do + web_hook_logs_entries = tables_registered_for_sync.select { |model| model.table_name == 'web_hook_logs' } + expect(web_hook_logs_entries.count).to eq(1) + web_hook_logs_entry = web_hook_logs_entries.first + expect(web_hook_logs_entry.partitioning_strategy).to be_a(Gitlab::Database::Partitioning::Time::MonthlyStrategy) + expect(web_hook_logs_entry.partitioning_strategy.retain_for).to eq(1.month) + end + + it 'registers web_hook_logs_daily for daily partitioning' do + web_hook_logs_daily_entries = tables_registered_for_sync + .select { |model| model.table_name == 'web_hook_logs_daily' } + expect(web_hook_logs_daily_entries.count).to eq(1) + web_hook_logs_daily_entry = web_hook_logs_daily_entries.first + expect(web_hook_logs_daily_entry.partitioning_strategy) + .to be_a(Gitlab::Database::Partitioning::Time::DailyStrategy) + expect(web_hook_logs_daily_entry.partitioning_strategy.retain_for).to eq(14.days) + end + end + context 'with ff enabled' do it 'returns daily partitioned table' do expect(described_class.table_name).to eq('web_hook_logs_daily') end + + it_behaves_like 'both tables are set up for partitioning' end context 'with ff disabled' do @@ -306,6 +333,8 @@ it 'returns monthly partitioned table' do expect(described_class.table_name).to eq('web_hook_logs') end + + it_behaves_like 'both tables are set up for partitioning' end end end diff --git a/yarn.lock b/yarn.lock index 0da735c6ffbcc93ec03039ba237dc5af2e0e1dd7..55e64491ca090841491c1d4f27151d3cb9015057 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1422,10 +1422,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/noop/-/noop-1.0.1.tgz#71a831146ee02732b4a61d2d3c11204564753454" integrity sha512-s++4wjMYeDvBp9IO59DBrWjy8SE/gFkjTDO5ck2W0S6Vv7OlqgErwL7pHngAnrSmTJAzyUG8wHGqo0ViS4jn5Q== -"@gitlab/query-language-rust@0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@gitlab/query-language-rust/-/query-language-rust-0.4.0.tgz#401be9312b3a18b026cd5e305dac579db8cc2e28" - integrity sha512-3tX5cFNxPakPqUzIXo2oD+yaI5x39fD4tA45SBDksHZYvojNIrohj1r2wH40w57OQnLyM1zMIocuZ95Sby40dQ== +"@gitlab/query-language-rust@0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@gitlab/query-language-rust/-/query-language-rust-0.4.2.tgz#82fa765fa75bd28eb2af08575203eeab95e214e4" + integrity sha512-ZPujJzNAJlIYPU57C+yUrzpIKL17yodVU0zBakqcCl/rtTpMhBEaLwY3Nv0V+SVPCG5h/e/up3TALqJyZAC4zg== "@gitlab/stylelint-config@6.2.2": version "6.2.2"