diff --git a/.gitlab/lint/unused_helper_methods/exluded_methods.yml b/.gitlab/lint/unused_helper_methods/exluded_methods.yml new file mode 100644 index 00000000000..c72d103d3cf --- /dev/null +++ b/.gitlab/lint/unused_helper_methods/exluded_methods.yml @@ -0,0 +1,13 @@ +# The methods listed here have been identified as "unused" by the linter +# scripts/lint/unused_helper_methods.rb, however subsequent research shows +# them to be false positives, and should be ignored when running that script. +# +toggle_award_emoji_personal_snippet_path: + file: app/helpers/routing/snippets_helper.rb + reason: Rails' dynamic route creation in app/helpers/award_emoji_helper.rb +toggle_award_emoji_project_project_snippet_path: + file: app/helpers/routing/snippets_helper.rb + reason: Rails' dynamic route creation in app/helpers/award_emoji_helper.rb +toggle_award_emoji_project_project_snippet_url: + file: app/helpers/routing/snippets_helper.rb + reason: Rails' dynamic route creation in app/helpers/award_emoji_helper.rb diff --git a/.rubocop_todo/gitlab/ee_only_class.yml b/.rubocop_todo/gitlab/ee_only_class.yml index 7ff29f83736..afbd335c7a3 100644 --- a/.rubocop_todo/gitlab/ee_only_class.yml +++ b/.rubocop_todo/gitlab/ee_only_class.yml @@ -106,7 +106,6 @@ Gitlab/EeOnlyClass: - 'ee/lib/ee/gitlab/checks/push_rules/file_size_check.rb' - 'ee/lib/ee/gitlab/checks/push_rules/tag_check.rb' - 'ee/lib/ee/gitlab/ci/variables/builder/scan_execution_policies.rb' - - 'ee/lib/ee/gitlab/import_export/after_export_strategies/custom_template_export_import_strategy.rb' - 'ee/lib/ee/gitlab/namespace_storage_size_error_message.rb' - 'ee/lib/ee/gitlab/personal_access_tokens/expiry_date_calculator.rb' - 'ee/lib/ee/gitlab/personal_access_tokens/service_account_token_validator.rb' diff --git a/.rubocop_todo/gitlab/strong_memoize_attr.yml b/.rubocop_todo/gitlab/strong_memoize_attr.yml index 0f40c57b584..67f6c0f9daa 100644 --- a/.rubocop_todo/gitlab/strong_memoize_attr.yml +++ b/.rubocop_todo/gitlab/strong_memoize_attr.yml @@ -349,7 +349,6 @@ Gitlab/StrongMemoizeAttr: - 'ee/lib/ee/gitlab/etag_caching/router/rails.rb' - 'ee/lib/ee/gitlab/git_access.rb' - 'ee/lib/ee/gitlab/gitaly_client/with_feature_flag_actors.rb' - - 'ee/lib/ee/gitlab/import_export/after_export_strategies/custom_template_export_import_strategy.rb' - 'ee/lib/ee/gitlab/issuable_metadata.rb' - 'ee/lib/ee/gitlab/security/scan_configuration.rb' - 'ee/lib/ee/gitlab/web_hooks/rate_limiter.rb' diff --git a/.rubocop_todo/layout/line_break_after_final_mixin.yml b/.rubocop_todo/layout/line_break_after_final_mixin.yml index da653e7f0c8..e441df8820c 100644 --- a/.rubocop_todo/layout/line_break_after_final_mixin.yml +++ b/.rubocop_todo/layout/line_break_after_final_mixin.yml @@ -39,7 +39,6 @@ Layout/LineBreakAfterFinalMixin: - 'app/workers/users/create_statistics_worker.rb' - 'ee/app/graphql/subscriptions/ai_completion_response.rb' - 'ee/app/services/ee/ip_restrictions/update_service.rb' - - 'ee/app/services/gitlab_subscriptions/trials/base_apply_trial_service.rb' - 'ee/app/services/incident_management/oncall_rotations/edit_service.rb' - 'ee/app/services/incident_management/oncall_rotations/remove_participant_service.rb' - 'ee/app/services/llm/generate_description_service.rb' diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml index c9de674bb40..2d18ae8f1be 100644 --- a/.rubocop_todo/layout/line_length.yml +++ b/.rubocop_todo/layout/line_length.yml @@ -831,7 +831,6 @@ Layout/LineLength: - 'ee/lib/ee/gitlab/ci/pipeline/chain/validate/security_orchestration_policy.rb' - 'ee/lib/ee/gitlab/ci/status/build/manual.rb' - 'ee/lib/ee/gitlab/git_access.rb' - - 'ee/lib/ee/gitlab/import_export/after_export_strategies/custom_template_export_import_strategy.rb' - 'ee/lib/ee/gitlab/middleware/read_only/controller.rb' - 'ee/lib/ee/gitlab/project_template.rb' - 'ee/lib/ee/gitlab/quick_actions/epic_actions.rb' diff --git a/.rubocop_todo/style/inline_disable_annotation.yml b/.rubocop_todo/style/inline_disable_annotation.yml index 250b5a95be0..9097dfcb599 100644 --- a/.rubocop_todo/style/inline_disable_annotation.yml +++ b/.rubocop_todo/style/inline_disable_annotation.yml @@ -1447,7 +1447,6 @@ Style/InlineDisableAnnotation: - 'ee/lib/ee/gitlab/ci/status/build/waiting_for_approval.rb' - 'ee/lib/ee/gitlab/exclusive_lease.rb' - 'ee/lib/ee/gitlab/geo_git_access.rb' - - 'ee/lib/ee/gitlab/import_export/after_export_strategies/custom_template_export_import_strategy.rb' - 'ee/lib/ee/gitlab/import_export/project/object_builder.rb' - 'ee/lib/ee/gitlab/object_hierarchy.rb' - 'ee/lib/ee/gitlab/quick_actions/users_extractor.rb' diff --git a/app/assets/javascripts/lib/utils/object_utils.js b/app/assets/javascripts/lib/utils/object_utils.js new file mode 100644 index 00000000000..844c781b817 --- /dev/null +++ b/app/assets/javascripts/lib/utils/object_utils.js @@ -0,0 +1,24 @@ +import { camelCase } from 'lodash'; + +/** + * Transforms object keys + * + * @param {Object} object + * @param {Function} transformer + * @return {Object} + */ +export function transformKeys(object, transformer) { + return Object.fromEntries( + Object.entries(object).map(([key, value]) => [transformer(key), value]), + ); +} + +/** + * Transform object keys to camelCase + * + * @param {Object} object + * @return {Object} + */ +export function camelizeKeys(object) { + return transformKeys(object, camelCase); +} diff --git a/app/assets/javascripts/rapid_diffs/app/chrome_fix.js b/app/assets/javascripts/rapid_diffs/app/chrome_fix.js index 0ed51947006..d8048c78b78 100644 --- a/app/assets/javascripts/rapid_diffs/app/chrome_fix.js +++ b/app/assets/javascripts/rapid_diffs/app/chrome_fix.js @@ -1,11 +1,9 @@ // content-visibility was fixed in Chrome 138, older versions are way too laggy with is so we just disable the feature // https://issues.chromium.org/issues/40066846 -export const disableContentVisibilityOnOlderChrome = () => { +export const disableContentVisibilityOnOlderChrome = (root) => { if (!/Chrome/.test(navigator.userAgent)) return; const chromeVersion = parseInt(navigator.userAgent.match(/Chrome\/(\d+)/)[1], 10); if (chromeVersion < 138) { - document - .querySelector('[data-rapid-diffs]') - .style.setProperty('--rd-content-visibility-auto', 'visible'); + root.style.setProperty('--rd-content-visibility-auto', 'visible'); } }; diff --git a/app/assets/javascripts/rapid_diffs/app/index.js b/app/assets/javascripts/rapid_diffs/app/index.js index d615f806289..de3200801de 100644 --- a/app/assets/javascripts/rapid_diffs/app/index.js +++ b/app/assets/javascripts/rapid_diffs/app/index.js @@ -1,7 +1,7 @@ +// eslint-disable-next-line max-classes-per-file import { pinia } from '~/pinia/instance'; import { initViewSettings } from '~/rapid_diffs/app/view_settings'; import { DiffFile } from '~/rapid_diffs/diff_file'; -import { DiffFileMounted } from '~/rapid_diffs/diff_file_mounted'; import { useDiffsList } from '~/rapid_diffs/stores/diffs_list'; import { initFileBrowser } from '~/rapid_diffs/app/init_file_browser'; import { StreamingError } from '~/rapid_diffs/streaming_error'; @@ -11,26 +11,65 @@ import { createAlert } from '~/alert'; import { __ } from '~/locale'; import { fixWebComponentsStreamingOnSafari } from '~/rapid_diffs/app/safari_fix'; import { DIFF_FILE_MOUNTED } from '~/rapid_diffs/dom_events'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { VIEWER_ADAPTERS } from '~/rapid_diffs/adapters'; +import { camelizeKeys } from '~/lib/utils/object_utils'; import { disableContentVisibilityOnOlderChrome } from '~/rapid_diffs/app/chrome_fix'; // This facade interface joins together all the bits and pieces of Rapid Diffs: DiffFile, Settings, File browser, etc. // It's a unified entrypoint for Rapid Diffs and all external communications should happen through this interface. -class RapidDiffsFacade { +export class RapidDiffsFacade { + root; + appData; + adapterConfig = VIEWER_ADAPTERS; + constructor({ DiffFileImplementation = DiffFile } = {}) { this.DiffFileImplementation = DiffFileImplementation; + this.root = document.querySelector('[data-rapid-diffs]'); + this.appData = camelizeKeys(JSON.parse(this.root.dataset.appData)); + this.streamRemainingDiffs = this.#streamRemainingDiffs.bind(this); + this.reloadDiffs = this.#reloadDiffs.bind(this); } init() { this.#registerCustomElements(); - disableContentVisibilityOnOlderChrome(); - fixWebComponentsStreamingOnSafari( - document.querySelector('[data-diffs-list]'), - this.DiffFileImplementation, + this.#initHeader(); + this.#initSidebar(); + this.#initDiffsList(); + } + + #streamRemainingDiffs() { + const streamContainer = this.root.querySelector('[data-stream-remaining-diffs]'); + if (!streamContainer) return Promise.resolve(); + return useDiffsList(pinia).streamRemainingDiffs( + this.appData.diffsStreamUrl, + streamContainer, + window.gl.rapidDiffsPreload, ); - const { reloadStreamUrl, diffsStatsEndpoint, diffFilesEndpoint, shouldSortMetadataFiles } = - document.querySelector('[data-rapid-diffs]').dataset; - useDiffsView(pinia).diffsStatsEndpoint = diffsStatsEndpoint; + } + + #reloadDiffs(initial) { + return useDiffsList(pinia).reloadDiffs(this.appData.reloadStreamUrl, initial); + } + + #registerCustomElements() { + window.customElements.define('diff-file', this.DiffFileImplementation); + window.customElements.define('diff-file-mounted', this.#DiffFileMounted); + window.customElements.define('streaming-error', StreamingError); + fixWebComponentsStreamingOnSafari(this.root, this.DiffFileImplementation); + } + + get #DiffFileMounted() { + const appContext = this; + return class extends HTMLElement { + connectedCallback() { + this.parentElement.mount(appContext); + } + }; + } + + #initHeader() { + useDiffsView(pinia).diffsStatsEndpoint = this.appData.diffsStatsEndpoint; + useDiffsView(pinia).streamUrl = this.appData.reloadStreamUrl; useDiffsView(pinia) .loadDiffsStats() .catch((error) => { @@ -39,36 +78,30 @@ class RapidDiffsFacade { error, }); }); - initFileBrowser(diffFilesEndpoint, parseBoolean(shouldSortMetadataFiles)).catch((error) => { + initViewSettings({ + pinia, + target: this.root.querySelector('[data-view-settings]'), + appData: this.appData, + }); + } + + #initSidebar() { + initFileBrowser({ + toggleTarget: this.root.querySelector('[data-file-browser-toggle]'), + browserTarget: this.root.querySelector('[data-file-browser]'), + appData: this.appData, + }).catch((error) => { createAlert({ message: __('Failed to load file browser. Try reloading the page.'), error, }); }); - initViewSettings({ pinia, streamUrl: reloadStreamUrl }); - initHiddenFilesWarning(); - document.addEventListener(DIFF_FILE_MOUNTED, useDiffsList(pinia).addLoadedFile); } - // eslint-disable-next-line class-methods-use-this - streamRemainingDiffs() { - const streamContainer = document.getElementById('js-stream-container'); - if (!streamContainer) return Promise.resolve(); - - return useDiffsList(pinia).streamRemainingDiffs(streamContainer.dataset.diffsStreamUrl); - } - - // eslint-disable-next-line class-methods-use-this - reloadDiffs(initial) { - const { reloadStreamUrl } = document.querySelector('[data-rapid-diffs]').dataset; - - return useDiffsList(pinia).reloadDiffs(reloadStreamUrl, initial); - } - - #registerCustomElements() { - window.customElements.define('diff-file', this.DiffFileImplementation); - window.customElements.define('diff-file-mounted', DiffFileMounted); - window.customElements.define('streaming-error', StreamingError); + #initDiffsList() { + disableContentVisibilityOnOlderChrome(this.root); + initHiddenFilesWarning(this.root.querySelector('[data-hidden-files-warning]')); + this.root.addEventListener(DIFF_FILE_MOUNTED, useDiffsList(pinia).addLoadedFile); } } diff --git a/app/assets/javascripts/rapid_diffs/app/init_file_browser.js b/app/assets/javascripts/rapid_diffs/app/init_file_browser.js index 940a725a9fe..bda3f82d985 100644 --- a/app/assets/javascripts/rapid_diffs/app/init_file_browser.js +++ b/app/assets/javascripts/rapid_diffs/app/init_file_browser.js @@ -18,9 +18,7 @@ const loadFileBrowserData = async (diffFilesEndpoint, shouldSort) => { }); }; -const initToggle = () => { - const el = document.querySelector('[data-file-browser-toggle]'); - +const initToggle = (el) => { // eslint-disable-next-line no-new new Vue({ el, @@ -31,8 +29,7 @@ const initToggle = () => { }); }; -const initBrowserComponent = async (shouldSort) => { - const el = document.querySelector('[data-file-browser]'); +const initBrowserComponent = async (el, shouldSort) => { // eslint-disable-next-line no-new new Vue({ el, @@ -52,8 +49,8 @@ const initBrowserComponent = async (shouldSort) => { }); }; -export async function initFileBrowser(diffFilesEndpoint, shouldSort) { - initToggle(); - await loadFileBrowserData(diffFilesEndpoint, shouldSort); - initBrowserComponent(shouldSort); +export async function initFileBrowser({ toggleTarget, browserTarget, appData }) { + initToggle(toggleTarget); + await loadFileBrowserData(appData.diffFilesEndpoint, appData.shouldSortMetadataFiles); + initBrowserComponent(browserTarget, appData.shouldSortMetadataFiles); } diff --git a/app/assets/javascripts/rapid_diffs/app/init_hidden_files_warning.js b/app/assets/javascripts/rapid_diffs/app/init_hidden_files_warning.js index b742d962b3d..1d04dfe3b33 100644 --- a/app/assets/javascripts/rapid_diffs/app/init_hidden_files_warning.js +++ b/app/assets/javascripts/rapid_diffs/app/init_hidden_files_warning.js @@ -4,9 +4,7 @@ import { useDiffsView } from '~/rapid_diffs/stores/diffs_view'; import { pinia } from '~/pinia/instance'; import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; -export async function initHiddenFilesWarning() { - const el = document.querySelector('[data-hidden-files-warning]'); - +export async function initHiddenFilesWarning(el) { // eslint-disable-next-line no-new new Vue({ el, diff --git a/app/assets/javascripts/rapid_diffs/app/view_settings.js b/app/assets/javascripts/rapid_diffs/app/view_settings.js index d4d00352830..b2e694c65a2 100644 --- a/app/assets/javascripts/rapid_diffs/app/view_settings.js +++ b/app/assets/javascripts/rapid_diffs/app/view_settings.js @@ -56,14 +56,12 @@ const initSettingsApp = (el, pinia) => { }); }; -export const initViewSettings = ({ pinia, streamUrl }) => { - const target = document.querySelector('[data-view-settings]'); - const { showWhitespace, diffViewType, updateUserEndpoint } = target.dataset; +export const initViewSettings = ({ pinia, target, appData }) => { + const { showWhitespace, diffViewType, updateUserEndpoint } = appData; useDiffsView(pinia).$patch({ showWhitespace: parseBoolean(showWhitespace), viewType: diffViewType, updateUserEndpoint, - streamUrl, }); useDiffsList(pinia).fillInLoadedFiles(); return initSettingsApp(target, pinia); diff --git a/app/assets/javascripts/rapid_diffs/diff_file.js b/app/assets/javascripts/rapid_diffs/diff_file.js index 2333a1f09d4..7d1c26aba55 100644 --- a/app/assets/javascripts/rapid_diffs/diff_file.js +++ b/app/assets/javascripts/rapid_diffs/diff_file.js @@ -1,5 +1,6 @@ +/** @typedef {import('./app/index.js').RapidDiffsFacade} */ +import { camelizeKeys } from '~/lib/utils/object_utils'; import { DIFF_FILE_MOUNTED } from './dom_events'; -import { VIEWER_ADAPTERS } from './adapters'; // required for easier mocking in tests import IntersectionObserver from './intersection_observer'; import * as events from './events'; @@ -16,14 +17,15 @@ const sharedObserver = new IntersectionObserver((entries) => { }); }); +const dataCacheKey = Symbol('data'); + export class DiffFile extends HTMLElement { + /** @param {RapidDiffsFacade} app */ + app; diffElement; - viewer; // intermediate state storage for adapters sink = {}; - adapterConfig = VIEWER_ADAPTERS; - static findByFileHash(hash) { return document.querySelector(`diff-file[id="${hash}"]`); } @@ -32,17 +34,30 @@ export class DiffFile extends HTMLElement { return Array.from(document.querySelectorAll('diff-file')); } - mount() { + // connectedCallback() is called immediately when the tag appears in DOM + // when we're streaming components their children might not be present at the moment this is called + // that's why we manually call mount() from component, which is always a last child + mount(app) { + this.app = app; const [diffElement] = this.children; this.diffElement = diffElement; - this.viewer = this.dataset.viewer; this.observeVisibility(); - this.diffElement.addEventListener('click', this.onClick.bind(this)); + this.onClickHandler = this.onClick.bind(this); + this.diffElement.addEventListener('click', this.onClickHandler); + this.trigger = this.#trigger.bind(this); this.trigger(events.MOUNTED); this.dispatchEvent(new CustomEvent(DIFF_FILE_MOUNTED, { bubbles: true })); } - trigger(event, ...args) { + disconnectedCallback() { + sharedObserver.unobserve(this); + this.diffElement.removeEventListener('click', this.onClickHandler); + this.app = null; + this.sink = null; + this.diffElement = null; + } + + #trigger(event, ...args) { if (!eventNames.includes(event)) throw new Error( `Missing event declaration: ${event}. Did you forget to declare this in ~/rapid_diffs/events.js?`, @@ -88,16 +103,14 @@ export class DiffFile extends HTMLElement { } get data() { - const data = { ...this.dataset }; - // viewer is dynamic, should be accessed via this.viewer - delete data.viewer; - return data; + if (!this[dataCacheKey]) this[dataCacheKey] = camelizeKeys(JSON.parse(this.dataset.fileData)); + return this[dataCacheKey]; } get adapterContext() { return { + appData: this.app.appData, diffElement: this.diffElement, - viewer: this.viewer, sink: this.sink, data: this.data, trigger: this.trigger, @@ -105,6 +118,6 @@ export class DiffFile extends HTMLElement { } get adapters() { - return this.adapterConfig[this.viewer] || []; + return this.app.adapterConfig[this.data.viewer] || []; } } diff --git a/app/assets/javascripts/rapid_diffs/diff_file_mounted.js b/app/assets/javascripts/rapid_diffs/diff_file_mounted.js deleted file mode 100644 index 87781420155..00000000000 --- a/app/assets/javascripts/rapid_diffs/diff_file_mounted.js +++ /dev/null @@ -1,5 +0,0 @@ -export class DiffFileMounted extends HTMLElement { - connectedCallback() { - this.parentElement.mount(); - } -} diff --git a/app/assets/javascripts/rapid_diffs/expand_lines/adapter.js b/app/assets/javascripts/rapid_diffs/expand_lines/adapter.js index f0a477ae35d..d9081970125 100644 --- a/app/assets/javascripts/rapid_diffs/expand_lines/adapter.js +++ b/app/assets/javascripts/rapid_diffs/expand_lines/adapter.js @@ -23,14 +23,14 @@ export const ExpandLinesAdapter = { hunkHeaderRow.dataset.loading = expandDirection; button.setAttribute('disabled', 'disabled'); - const { diffLinesPath } = this.data; + const { diffLinesPath, viewer } = this.data; let lines; try { lines = await getLines({ expandDirection, surroundingLines: getSurroundingLines(hunkHeaderRow), diffLinesPath, - view: this.viewer === 'text_parallel' ? 'parallel' : undefined, + view: viewer === 'text_parallel' ? 'parallel' : undefined, }); } catch (error) { createAlert({ diff --git a/app/assets/javascripts/rapid_diffs/stores/diffs_list.js b/app/assets/javascripts/rapid_diffs/stores/diffs_list.js index 318f8454b6c..8a15703f8a3 100644 --- a/app/assets/javascripts/rapid_diffs/stores/diffs_list.js +++ b/app/assets/javascripts/rapid_diffs/stores/diffs_list.js @@ -51,13 +51,13 @@ export const useDiffsList = defineStore('diffsList', { await renderHtmlStreams([stream], container, { signal }); this.status = statuses.idle; }, - streamRemainingDiffs(url) { + streamRemainingDiffs(url, target, preload) { return this.withDebouncedAbortController(async ({ signal }) => { this.status = statuses.fetching; let request; let streamSignal = signal; - if (window.gl.rapidDiffsPreload) { - const { controller, streamRequest } = window.gl.rapidDiffsPreload; + if (preload) { + const { controller, streamRequest } = preload; this.loadingController = controller; request = streamRequest; streamSignal = controller.signal; @@ -65,11 +65,7 @@ export const useDiffsList = defineStore('diffsList', { request = fetch(url, { signal }); } const { body } = await request; - await this.renderDiffsStream( - toPolyfillReadable(body), - document.querySelector('#js-stream-container'), - streamSignal, - ); + await this.renderDiffsStream(toPolyfillReadable(body), target, streamSignal); performanceMarkAndMeasure({ mark: 'rapid-diffs-list-loaded', measures: [ diff --git a/app/assets/stylesheets/components/rapid_diffs/text_file_viewers.scss b/app/assets/stylesheets/components/rapid_diffs/text_file_viewers.scss index 93f5ad2b12c..5f1737fc90e 100644 --- a/app/assets/stylesheets/components/rapid_diffs/text_file_viewers.scss +++ b/app/assets/stylesheets/components/rapid_diffs/text_file_viewers.scss @@ -230,7 +230,8 @@ background-color: var(--rd-line-background-color); &::before { - content: "-"; + // / "" allows us to not announce this with screen readers + content: "−" / ""; color: var(--code-old-diff-sign-color, scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%)); } } @@ -240,7 +241,8 @@ background-color: var(--rd-line-background-color); &::before { - content: "+"; + // / "" allows us to not announce this with screen readers + content: "+" / ""; color: var(--code-new-diff-sign-color, scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%)); } } diff --git a/app/components/rapid_diffs/app_component.html.haml b/app/components/rapid_diffs/app_component.html.haml index 85f20232521..0325ef21119 100644 --- a/app/components/rapid_diffs/app_component.html.haml +++ b/app/components/rapid_diffs/app_component.html.haml @@ -11,12 +11,12 @@ streamRequest: fetch('#{Gitlab::UrlSanitizer.sanitize(@stream_url)}', { signal: controller.signal }) } -.rd-app{ data: { rapid_diffs: true, reload_stream_url: @reload_stream_url, diffs_stats_endpoint: @diffs_stats_endpoint, diff_files_endpoint: @diff_files_endpoint, should_sort_metadata_files: @should_sort_metadata_files.to_json } } +.rd-app{ data: { rapid_diffs: true, app_data: app_data.to_json } } .rd-app-header .rd-app-file-browser-toggle %div{ data: { file_browser_toggle: true } } .rd-app-settings - %div{ data: { view_settings: true, show_whitespace: @show_whitespace.to_json, diff_view_type: @diff_view, update_user_endpoint: @update_user_endpoint } } + %div{ data: { view_settings: true } } .rd-app-body .rd-app-sidebar{ data: { file_browser: true }, style: sidebar_style } .rd-app-sidebar-loading{ data: { testid: 'rd-file-browser-loading' } } @@ -40,9 +40,9 @@ - if diffs_list? = diffs_list - elsif !empty_diff? - = render RapidDiffs::DiffFileComponent.with_collection(@diffs_slice, parallel_view: @diff_view == :parallel) + = render RapidDiffs::DiffFileComponent.with_collection(@diffs_slice, parallel_view: parallel_view?) - if @stream_url - #js-stream-container{ data: { diffs_stream_url: @stream_url } } + %div{ data: { stream_remaining_diffs: true } } - else = javascript_tag nonce: content_security_policy_nonce do :plain diff --git a/app/components/rapid_diffs/app_component.rb b/app/components/rapid_diffs/app_component.rb index 22136a9b67f..4509fb64312 100644 --- a/app/components/rapid_diffs/app_component.rb +++ b/app/components/rapid_diffs/app_component.rb @@ -28,6 +28,23 @@ module RapidDiffs @lazy = lazy end + def app_data + { + diffs_stream_url: @stream_url, + reload_stream_url: @reload_stream_url, + diffs_stats_endpoint: @diffs_stats_endpoint, + diff_files_endpoint: @diff_files_endpoint, + should_sort_metadata_files: @should_sort_metadata_files, + show_whitespace: @show_whitespace, + diff_view_type: @diff_view, + update_user_endpoint: @update_user_endpoint + } + end + + def parallel_view? + @diff_view == :parallel + end + def empty_diff? @diffs_slice.nil? || @diffs_slice.empty? end diff --git a/app/components/rapid_diffs/diff_file_component.html.haml b/app/components/rapid_diffs/diff_file_component.html.haml index 2ed79ced44a..0c8a5eb1325 100644 --- a/app/components/rapid_diffs/diff_file_component.html.haml +++ b/app/components/rapid_diffs/diff_file_component.html.haml @@ -1,6 +1,6 @@ -# TODO: add fork suggestion (commits only) -%diff-file.rd-diff-file-component{ id: id, data: { testid: 'rd-diff-file', **server_data } } +%diff-file.rd-diff-file-component{ id: id, data: { testid: 'rd-diff-file', file_data: file_data.to_json } } .rd-diff-file{ data: { virtual: virtual? }, style: ("--total-rows: #{total_rows}" if virtual?) } = header || default_header -# extra wrapper needed so content-visibility: hidden doesn't require removing border or other styles diff --git a/app/components/rapid_diffs/diff_file_component.rb b/app/components/rapid_diffs/diff_file_component.rb index e354e77fdd0..19679cb1c33 100644 --- a/app/components/rapid_diffs/diff_file_component.rb +++ b/app/components/rapid_diffs/diff_file_component.rb @@ -15,7 +15,7 @@ module RapidDiffs @diff_file.file_hash end - def server_data + def file_data project = @diff_file.repository.project params = tree_join(@diff_file.content_sha, @diff_file.file_path) { diff --git a/app/components/rapid_diffs/viewers/text/line_number_component.html.haml b/app/components/rapid_diffs/viewers/text/line_number_component.html.haml index 969f332cf3c..3ad13e78998 100644 --- a/app/components/rapid_diffs/viewers/text/line_number_component.html.haml +++ b/app/components/rapid_diffs/viewers/text/line_number_component.html.haml @@ -1,5 +1,6 @@ - if visible? %td.rd-line-number{ id: id, class: border_class, data: { legacy_id: legacy_id, change: change_type, position: @position } } - = link_to '', "##{id}", { class: 'rd-line-link', data: { line_number: line_number }, aria: { label: s_('Line number %{number}').html_safe % { number: line_number } } } + = link_to '', "##{id}", { class: 'rd-line-link', data: { line_number: line_number }, aria: { label: label } } - else - %td.rd-line-number{ class: border_class, data: { change: change_type } } + -# tabindex="-1" allows us to start text selection on an empty line number row + %td.rd-line-number{ class: border_class, data: { position: @position }, tabindex: '-1' } diff --git a/app/components/rapid_diffs/viewers/text/line_number_component.rb b/app/components/rapid_diffs/viewers/text/line_number_component.rb index 889268443be..11a21bfcc1c 100644 --- a/app/components/rapid_diffs/viewers/text/line_number_component.rb +++ b/app/components/rapid_diffs/viewers/text/line_number_component.rb @@ -25,8 +25,6 @@ module RapidDiffs end def change_type - return unless @line - return 'added' if @line.added? 'removed' if @line.removed? @@ -39,6 +37,13 @@ module RapidDiffs end end + def label + return s_('RapidDiffs|Removed line %d') % line_number if @line.removed? + return s_('RapidDiffs|Added line %d') % line_number if @line.added? + + s_('RapidDiffs|Line %d') % line_number + end + def visible? return false unless @line diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 10c4e5f719c..2804d47be87 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -181,6 +181,7 @@ module Projects destroy_mr_diff_relations! destroy_merge_request_diffs! + delete_environments # Rails attempts to load all related records into memory before # destroying: https://github.com/rails/rails/issues/22510 @@ -294,6 +295,23 @@ module Projects end end + def delete_environments + deleted_count = 0 + + loop do + deleted_rows = ::Environment.for_project(project).limit(BATCH_SIZE).delete_all + deleted_count += deleted_rows + break if deleted_rows < BATCH_SIZE + end + + Gitlab::AppLogger.info( + class: self.class.name, + project_id: project.id, + message: 'Deleting environments completed', + deleted_environment_count: deleted_count + ) + end + # The project can have multiple webhooks with hundreds of thousands of web_hook_logs. # By default, they are removed with "DELETE CASCADE" option defined via foreign_key. # But such queries can exceed the statement_timeout limit and fail to delete the project. diff --git a/app/views/groups/usage_quotas/root.html.haml b/app/views/groups/usage_quotas/root.html.haml index 9c385a06915..1d8a7943988 100644 --- a/app/views/groups/usage_quotas/root.html.haml +++ b/app/views/groups/usage_quotas/root.html.haml @@ -1,6 +1,8 @@ - content_for :usage_quotas_alerts do = render_if_exists 'shared/usage_quotas/alerts' += render_if_exists 'groups/usage_quotas/google_tag_manager' + - content_for :usage_quotas_subtitle do %span{ data: { testid: 'group-usage-message-content' } } = safe_format(s_('UsageQuota|Usage of group resources across the projects in the %{strong_start}%{group_name}%{strong_end} group'), diff --git a/app/views/shared/usage_quotas/_index.html.haml b/app/views/shared/usage_quotas/_index.html.haml index 6ee65d57815..3f45cf2c23f 100644 --- a/app/views/shared/usage_quotas/_index.html.haml +++ b/app/views/shared/usage_quotas/_index.html.haml @@ -1,10 +1,6 @@ - @force_desktop_expanded_sidebar = true - page_title s_("UsageQuota|Usage") -- content_for :page_specific_javascripts do - = render "layouts/one_trust" -= render_if_exists 'shared/usage_quotas/google_tag_manager' - = yield :usage_quotas_alerts = render ::Layouts::PageHeadingComponent.new(s_('UsageQuota|Usage Quotas')) do |c| diff --git a/db/docs/batched_background_migrations/delete_twitter_identities.yml b/db/docs/batched_background_migrations/delete_twitter_identities.yml new file mode 100644 index 00000000000..d36c7690e58 --- /dev/null +++ b/db/docs/batched_background_migrations/delete_twitter_identities.yml @@ -0,0 +1,8 @@ +--- +migration_job_name: DeleteTwitterIdentities +description: Deletes OmniAuth Twitter identities +feature_category: system_access +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/188411 +milestone: '18.0' +queued_migration_version: 20250415164706 +finalized_by: # version of the migration that finalized this BBM diff --git a/db/docs/ci_pipeline_variables.yml b/db/docs/deleted_tables/ci_pipeline_variables.yml similarity index 75% rename from db/docs/ci_pipeline_variables.yml rename to db/docs/deleted_tables/ci_pipeline_variables.yml index e6f23affecd..1fd5bf061a9 100644 --- a/db/docs/ci_pipeline_variables.yml +++ b/db/docs/deleted_tables/ci_pipeline_variables.yml @@ -11,3 +11,5 @@ gitlab_schema: gitlab_ci table_size: over_limit sharding_key: project_id: projects +removed_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/187555 +removed_in_milestone: '18.0' diff --git a/db/migrate/20250502052327_disable_product_usage_data_collection_for_offline_licenses.rb b/db/migrate/20250502052327_disable_product_usage_data_collection_for_offline_licenses.rb new file mode 100644 index 00000000000..83a46c2aa60 --- /dev/null +++ b/db/migrate/20250502052327_disable_product_usage_data_collection_for_offline_licenses.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class DisableProductUsageDataCollectionForOfflineLicenses < Gitlab::Database::Migration[2.3] + milestone '18.0' + disable_ddl_transaction! + restrict_gitlab_migration gitlab_schema: :gitlab_main + + def up + return unless Gitlab.ee? + + # for offline license we disable regardless other settings + if License.current&.offline_cloud_license? + execute <<~SQL + UPDATE application_settings + SET service_ping_settings = + COALESCE(service_ping_settings, '{}'::jsonb) || + jsonb_build_object('gitlab_product_usage_data_enabled', false) + SQL + else + # When operational metric is required in license, do not opt-out of product usage data + return if ::License.current&.customer_service_enabled? + + # When offline license is false and operational metric is optional, + # we opt-out of product usage data when usage ping is off + execute <<~SQL + UPDATE application_settings + SET service_ping_settings = + COALESCE(service_ping_settings, '{}'::jsonb) || + jsonb_build_object('gitlab_product_usage_data_enabled', false) + WHERE usage_ping_enabled = FALSE; + SQL + end + end +end diff --git a/db/post_migrate/20250415164706_queue_delete_twitter_identities.rb b/db/post_migrate/20250415164706_queue_delete_twitter_identities.rb new file mode 100644 index 00000000000..f868c2d57df --- /dev/null +++ b/db/post_migrate/20250415164706_queue_delete_twitter_identities.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class QueueDeleteTwitterIdentities < Gitlab::Database::Migration[2.2] + MIGRATION = 'DeleteTwitterIdentities' + + disable_ddl_transaction! + milestone '18.0' + restrict_gitlab_migration gitlab_schema: :gitlab_main_clusterwide + + def up + queue_batched_background_migration( + MIGRATION, + :identities, + :id + ) + end + + def down + delete_batched_background_migration(MIGRATION, :identities, :id, []) + end +end diff --git a/db/post_migrate/20250430010151_truncate_ci_build_trace_metadata_partition.rb b/db/post_migrate/20250430010151_truncate_ci_build_trace_metadata_partition.rb new file mode 100644 index 00000000000..0f97846559c --- /dev/null +++ b/db/post_migrate/20250430010151_truncate_ci_build_trace_metadata_partition.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class TruncateCiBuildTraceMetadataPartition < Gitlab::Database::Migration[2.3] + milestone '18.0' + + disable_ddl_transaction! + + PARTITION_NAME = 'gitlab_partitions_dynamic.ci_build_trace_metadata_102' + + def up + return unless Gitlab.com_except_jh? + + truncate_tables!(PARTITION_NAME) + end + + def down + # no-op + end +end diff --git a/db/post_migrate/20250508090147_move_pipeline_variables_to_dynamic_schema.rb b/db/post_migrate/20250508090147_move_pipeline_variables_to_dynamic_schema.rb new file mode 100644 index 00000000000..9b749bcd41f --- /dev/null +++ b/db/post_migrate/20250508090147_move_pipeline_variables_to_dynamic_schema.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class MovePipelineVariablesToDynamicSchema < Gitlab::Database::Migration[2.3] + milestone '18.0' + + DYNAMIC_SCHEMA = Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA + TABLE_NAME = :ci_pipeline_variables + + def up + connection.execute(<<~SQL) + ALTER TABLE IF EXISTS #{TABLE_NAME} SET SCHEMA #{DYNAMIC_SCHEMA}; + SQL + end + + def down + connection.execute(<<~SQL) + ALTER TABLE IF EXISTS #{DYNAMIC_SCHEMA}.#{TABLE_NAME} SET SCHEMA #{connection.current_schema}; + SQL + end +end diff --git a/db/schema_migrations/20250415164706 b/db/schema_migrations/20250415164706 new file mode 100644 index 00000000000..bfb650e36f6 --- /dev/null +++ b/db/schema_migrations/20250415164706 @@ -0,0 +1 @@ +767d1953591fc687c55239beb46722332d4d6a4e4b7889715bdb96aee8d8e1b9 \ No newline at end of file diff --git a/db/schema_migrations/20250430010151 b/db/schema_migrations/20250430010151 new file mode 100644 index 00000000000..22bf8c73e2c --- /dev/null +++ b/db/schema_migrations/20250430010151 @@ -0,0 +1 @@ +220efea4d6e0dae03de421c80200d4b9707a12526e4ba0dcace95cd7241271fd \ No newline at end of file diff --git a/db/schema_migrations/20250502052327 b/db/schema_migrations/20250502052327 new file mode 100644 index 00000000000..d959380cffa --- /dev/null +++ b/db/schema_migrations/20250502052327 @@ -0,0 +1 @@ +6684c5815a4272be61d14dfbedfd76760a45324daafa60f31c304ce916ad4bef \ No newline at end of file diff --git a/db/schema_migrations/20250508090147 b/db/schema_migrations/20250508090147 new file mode 100644 index 00000000000..70932752cc1 --- /dev/null +++ b/db/schema_migrations/20250508090147 @@ -0,0 +1 @@ +2394c7bb37526a05b24e37d802e2db70a7fc702e5189aa08c5ccdea8ddf0a7b9 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 86095c9a405..90e198579c8 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -11479,21 +11479,6 @@ CREATE SEQUENCE ci_pipeline_schedules_id_seq ALTER SEQUENCE ci_pipeline_schedules_id_seq OWNED BY ci_pipeline_schedules.id; -CREATE TABLE ci_pipeline_variables ( - key character varying NOT NULL, - value text, - encrypted_value text, - encrypted_value_salt character varying, - encrypted_value_iv character varying, - variable_type smallint DEFAULT 1 NOT NULL, - partition_id bigint NOT NULL, - raw boolean DEFAULT false NOT NULL, - id bigint NOT NULL, - pipeline_id bigint NOT NULL, - project_id bigint, - CONSTRAINT check_6e932dbabf CHECK ((project_id IS NOT NULL)) -); - CREATE SEQUENCE ci_pipeline_variables_id_seq START WITH 1 INCREMENT BY 1 @@ -26579,8 +26564,6 @@ ALTER TABLE ONLY p_ci_builds_metadata ATTACH PARTITION ci_builds_metadata FOR VA ALTER TABLE ONLY p_ci_job_artifacts ATTACH PARTITION ci_job_artifacts FOR VALUES IN ('100', '101'); -ALTER TABLE ONLY p_ci_pipeline_variables ATTACH PARTITION ci_pipeline_variables FOR VALUES IN ('100', '101'); - ALTER TABLE ONLY p_ci_pipelines ATTACH PARTITION ci_pipelines FOR VALUES IN ('100', '101', '102'); ALTER TABLE ONLY ci_runner_taggings ATTACH PARTITION ci_runner_taggings_group_type FOR VALUES IN ('2'); @@ -29187,12 +29170,6 @@ ALTER TABLE ONLY ci_pipeline_schedule_variables ALTER TABLE ONLY ci_pipeline_schedules ADD CONSTRAINT ci_pipeline_schedules_pkey PRIMARY KEY (id); -ALTER TABLE ONLY p_ci_pipeline_variables - ADD CONSTRAINT p_ci_pipeline_variables_pkey PRIMARY KEY (id, partition_id); - -ALTER TABLE ONLY ci_pipeline_variables - ADD CONSTRAINT ci_pipeline_variables_pkey PRIMARY KEY (id, partition_id); - ALTER TABLE ONLY p_ci_pipelines ADD CONSTRAINT p_ci_pipelines_pkey PRIMARY KEY (id, partition_id); @@ -30267,6 +30244,9 @@ ALTER TABLE ONLY p_ci_job_annotations ALTER TABLE ONLY p_ci_job_artifact_reports ADD CONSTRAINT p_ci_job_artifact_reports_pkey PRIMARY KEY (job_artifact_id, partition_id); +ALTER TABLE ONLY p_ci_pipeline_variables + ADD CONSTRAINT p_ci_pipeline_variables_pkey PRIMARY KEY (id, partition_id); + ALTER TABLE ONLY p_ci_pipelines_config ADD CONSTRAINT p_ci_pipelines_config_pkey PRIMARY KEY (pipeline_id, partition_id); @@ -36598,10 +36578,6 @@ CREATE INDEX index_personal_access_tokens_on_user_id ON personal_access_tokens U CREATE INDEX index_pipeline_metadata_on_name_text_pattern_pipeline_id ON ci_pipeline_metadata USING btree (name text_pattern_ops, pipeline_id); -CREATE UNIQUE INDEX p_ci_pipeline_variables_pipeline_id_key_partition_id_idx ON ONLY p_ci_pipeline_variables USING btree (pipeline_id, key, partition_id); - -CREATE UNIQUE INDEX index_pipeline_variables_on_pipeline_id_key_partition_id_unique ON ci_pipeline_variables USING btree (pipeline_id, key, partition_id); - CREATE INDEX index_pipl_users_on_initial_email_sent_at ON pipl_users USING btree (initial_email_sent_at); CREATE UNIQUE INDEX index_plan_limits_on_plan_id ON plan_limits USING btree (plan_id); @@ -38276,6 +38252,8 @@ CREATE UNIQUE INDEX p_ci_builds_token_encrypted_partition_id_idx ON ONLY p_ci_bu CREATE INDEX p_ci_job_artifacts_expire_at_job_id_idx1 ON ONLY p_ci_job_artifacts USING btree (expire_at, job_id) WHERE ((locked = 2) AND (expire_at IS NOT NULL)); +CREATE UNIQUE INDEX p_ci_pipeline_variables_pipeline_id_key_partition_id_idx ON ONLY p_ci_pipeline_variables USING btree (pipeline_id, key, partition_id); + CREATE UNIQUE INDEX p_ci_workloads_pipeline_id_idx ON ONLY p_ci_workloads USING btree (pipeline_id, partition_id); CREATE INDEX package_name_index ON packages_packages USING btree (name); @@ -40474,8 +40452,6 @@ ALTER INDEX p_ci_builds_pkey ATTACH PARTITION ci_builds_pkey; ALTER INDEX p_ci_job_artifacts_pkey ATTACH PARTITION ci_job_artifacts_pkey; -ALTER INDEX p_ci_pipeline_variables_pkey ATTACH PARTITION ci_pipeline_variables_pkey; - ALTER INDEX p_ci_pipelines_pkey ATTACH PARTITION ci_pipelines_pkey; ALTER INDEX ci_runner_taggings_pkey ATTACH PARTITION ci_runner_taggings_group_type_pkey; @@ -40760,8 +40736,6 @@ ALTER INDEX p_ci_pipelines_trigger_id_id_desc_idx ATTACH PARTITION index_d8ae6ea ALTER INDEX p_ci_builds_user_id_name_idx ATTACH PARTITION index_partial_ci_builds_on_user_id_name_parser_features; -ALTER INDEX p_ci_pipeline_variables_pipeline_id_key_partition_id_idx ATTACH PARTITION index_pipeline_variables_on_pipeline_id_key_partition_id_unique; - ALTER INDEX p_ci_builds_user_id_name_created_at_idx ATTACH PARTITION index_secure_ci_builds_on_user_id_name_created_at; ALTER INDEX p_ci_builds_name_id_idx ATTACH PARTITION index_security_ci_builds_on_name_and_id_parser_features; diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 0b34c3d8ab8..d59312c6122 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -5628,9 +5628,8 @@ Input type: `DuoSettingsUpdateInput` | Name | Type | Description | | ---- | ---- | ----------- | -| `aiGatewayUrl` | [`String`](#string) | URL for local AI gateway server. | | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | -| `duoCoreFeaturesEnabled` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Deprecated:** **Status**: Experiment. Introduced in GitLab 18.0. | +| `duoSettings` | [`DuoSettings!`](#duosettings) | GitLab Duo settings after mutation. | | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | ### `Mutation.duoUserFeedback` @@ -44334,7 +44333,7 @@ Member role permission. | `ADMIN_INTEGRATIONS` | Create, read, update, and delete integrations with external applications. | | `ADMIN_MERGE_REQUEST` | Allows approval of merge requests. | | `ADMIN_PROTECTED_BRANCH` | Create, read, update, and delete protected branches for a project. | -| `ADMIN_PROTECTED_ENVIRONMENTS` | Create, read, update, and delete environments. | +| `ADMIN_PROTECTED_ENVIRONMENTS` | Create, read, update, and delete protected environments. | | `ADMIN_PUSH_RULES` | Configure push rules for repositories at the group or project level. | | `ADMIN_RUNNERS` | Create, view, edit, and delete group or project Runners. Includes configuring Runner settings. | | `ADMIN_SECURITY_TESTING` | Edit and manage security testing configurations and settings. | @@ -44374,7 +44373,7 @@ Member role standard permission. | `ADMIN_INTEGRATIONS` | Create, read, update, and delete integrations with external applications. | | `ADMIN_MERGE_REQUEST` | Allows approval of merge requests. | | `ADMIN_PROTECTED_BRANCH` | Create, read, update, and delete protected branches for a project. | -| `ADMIN_PROTECTED_ENVIRONMENTS` {{< icon name="warning-solid" >}} | **Introduced** in GitLab 17.9. **Status**: Experiment. Create, read, update, and delete environments. | +| `ADMIN_PROTECTED_ENVIRONMENTS` | Create, read, update, and delete protected environments. | | `ADMIN_PUSH_RULES` | Configure push rules for repositories at the group or project level. | | `ADMIN_RUNNERS` | Create, view, edit, and delete group or project Runners. Includes configuring Runner settings. | | `ADMIN_SECURITY_TESTING` {{< icon name="warning-solid" >}} | **Introduced** in GitLab 17.9. **Status**: Experiment. Edit and manage security testing configurations and settings. | diff --git a/doc/development/project_templates/_index.md b/doc/development/project_templates/_index.md index eb1f5b7e89c..9fd92a96eb2 100644 --- a/doc/development/project_templates/_index.md +++ b/doc/development/project_templates/_index.md @@ -99,8 +99,8 @@ steps: - Defined in `lib/gitlab/export/logger.rb`. - `Gitlab::ImportExport::LogUtil`: Builds log messages. - Defined in `lib/gitlab/import_export/log_util.rb`. -- `Gitlab::ImportExport::AfterExportStrategies::CustomTemplateExportImportStrategy`: Callback class to import the template after it has been exported. - - Defined in `ee/lib/ee/gitlab/import_export/after_export_strategies/custom_template_export_import_strategy.rb`. +- `Import::AfterExportStrategies::CustomTemplateExportImportStrategy`: Callback class to import the template after it has been exported. + - Defined in `ee/lib/import/after_export_strategies/custom_template_export_import_strategy.rb`. - `Gitlab::TemplateHelper`: Helpers for importing templates. - Defined in `lib/gitlab/template_helper.rb`. - `ImportExportUpload`: Stores the import and export archive files. diff --git a/doc/subscriptions/subscription-add-ons.md b/doc/subscriptions/subscription-add-ons.md index 1d0a98b6cb7..0f00203995f 100644 --- a/doc/subscriptions/subscription-add-ons.md +++ b/doc/subscriptions/subscription-add-ons.md @@ -13,17 +13,55 @@ title: GitLab Duo add-ons {{< /details >}} +{{< history >}} + +- Changed to include GitLab Duo Core add-on in GitLab 18.0. + +{{< /history >}} + GitLab Duo add-ons extend your Premium or Ultimate subscription with AI-native features. Use GitLab Duo to help accelerate development workflows, reduce repetitive coding tasks, and gain deeper insights across your projects. -Purchase GitLab Duo seats and assign them to team members. +Three add-ons are available: GitLab Duo Core, Pro, and Enterprise. + +Each add-on provides access to +[a set of GitLab Duo features](../user/gitlab_duo/_index.md#summary-of-gitlab-duo-features). + +## GitLab Duo Core + +GitLab Duo Core is included automatically if you have: + +- GitLab 18.0 or later. +- A Premium or Ultimate subscription. + +You only need to [turn on IDE features](../user/gitlab_duo/turn_on_off.md#change-gitlab-duo-core-availability) +to start using GitLab Duo in your IDEs. No further action is needed. + +Users assigned the [Guest role](../administration/guest_users.md) do not have +access to GitLab Duo Core. + +{{< alert type="note" >}} + +Your eligibility for GitLab Duo Core may be subject to rate limits. + +{{< /alert >}} + +## GitLab Duo Pro and Enterprise + +GitLab Duo Pro and Enterprise require you to purchase seats and assign them to team members. The seat-based model gives you control over feature access and cost management based on your specific team needs. ## Purchase GitLab Duo -To purchase GitLab Duo Pro seats, you can use the Customers Portal, or you can contact the [GitLab Sales team](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/). To purchase GitLab Duo Enterprise, contact the [GitLab Sales team](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/). +To purchase GitLab Duo Enterprise, contact the +[GitLab Sales team](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/). + +To purchase seats for GitLab Duo Pro, use the Customers Portal or +contact the [GitLab Sales team](https://about.gitlab.com/solutions/gitlab-duo-pro/sales/). + +To use the portal: 1. Sign in to the [GitLab Customers Portal](https://customers.gitlab.com/). 1. On the subscription card, select the vertical ellipsis ({{< icon name="ellipsis_v" >}}). @@ -75,12 +113,12 @@ Prerequisites: Prerequisites: -- You must purchase a GitLab Duo add-on, or have an active GitLab Duo trial. +- You must purchase a GitLab Duo Pro or Enterprise add-on, or have an active GitLab Duo trial. - For GitLab Self-Managed and GitLab Dedicated: - The GitLab Duo Pro add-on is available in GitLab 16.8 and later. - The GitLab Duo Enterprise add-on is only available in GitLab 17.3 and later. -After you purchase GitLab Duo, you can assign seats to users to grant access to the add-on. +After you purchase GitLab Duo Pro or Enterprise, you can assign seats to users to grant access to the add-on. ### For GitLab.com @@ -182,9 +220,10 @@ For more information, see [GitLab Duo add-on seat management with LDAP](../admin Prerequisites: -- You must purchase a GitLab Duo add-on, or have an active GitLab Duo trial. +- You must purchase a GitLab Duo Pro or Enterprise add-on, or have an active GitLab Duo trial. -After you purchase GitLab Duo, you can assign seats to users to grant access to the add-on. Then you can view details of assigned GitLab Duo users. +After you purchase GitLab Duo Pro or Enterprise, you can assign seats to users to +grant access to the add-on. Then you can view details of assigned GitLab Duo users. The GitLab Duo seat utilization page shows the following information for each user: diff --git a/doc/user/custom_roles/abilities.md b/doc/user/custom_roles/abilities.md index b7e4ea7fa17..3fc9468451e 100644 --- a/doc/user/custom_roles/abilities.md +++ b/doc/user/custom_roles/abilities.md @@ -43,6 +43,7 @@ Any dependencies are noted in the `Description` column for each permission. | Permission | Description | API Attribute | Scope | Introduced | |:-----------|:------------|:--------------|:------|:-----------| | Manage deploy tokens | Manage deploy tokens at the group or project level. | [`manage_deploy_tokens`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/151677) | Group,
Project | GitLab [17.0](https://gitlab.com/gitlab-org/gitlab/-/issues/448843) | +| Manage Protected Environments | Create, read, update, and delete protected environments | [`admin_protected_environments`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/178283) | Group,
Project | GitLab [17.9](https://gitlab.com/gitlab-org/gitlab/-/issues/471385) | ## Groups and projects diff --git a/doc/user/get_started/getting_started_gitlab_duo.md b/doc/user/get_started/getting_started_gitlab_duo.md index 4932874c967..dfbb24cb5ec 100644 --- a/doc/user/get_started/getting_started_gitlab_duo.md +++ b/doc/user/get_started/getting_started_gitlab_duo.md @@ -10,39 +10,33 @@ GitLab Duo is your AI-native assistant. It can help you write, review, and edit along with a variety of other tasks throughout your GitLab workflow. It can help you troubleshoot your pipeline, write tests, address vulnerabilities, and more. -## Step 1: Ensure you have a subscription +## Step 1: Ensure you have access to GitLab Duo -Your organization has purchased a GitLab Duo add-on subscription: either GitLab Duo Pro or Duo Enterprise. -Each subscription includes a set of AI-native features to help improve your workflow. +To get started with GitLab Duo, your organization must have a Premium or Ultimate subscription +and a GitLab Duo add-on. -After your organization purchases a subscription, an administrator must assign seats to users. -You likely received an email that notified you of your seat. +Your add-on determines the GitLab Duo features you have access to. -The AI-native features you have access to use language models to help streamline -your workflow: +- The GitLab Duo Core add-on comes with all Premium and Ultimate subscriptions. +- The GitLab Duo Pro and GitLab Duo Enterprise add-ons are available for purchase. -- If you're on GitLab.com, you use default GitLab AI vendor models. -- If you're on GitLab Self-Managed, your administrator can either: - - [Use default GitLab AI vendor models](../gitlab_duo/setup.md). - - Self-host the AI gateway and language models with - [GitLab Duo Self-Hosted](../../administration/gitlab_duo_self_hosted/_index.md) - and choose from among supported models. +For GitLab Duo features, your organization can use the GitLab default language models +or host their own models by using GitLab Duo Self-Hosted. -If you have issues accessing GitLab Duo features, ask your administrator. -They can check the health of the installation. +If you have issues accessing GitLab Duo features, your administrators +can check the health of the installation. For more information, see: -- [Assign seats to users](../../subscriptions/subscription-add-ons.md#assign-gitlab-duo-seats). -- [Features included in Duo Pro and Duo Enterprise](https://about.gitlab.com/gitlab-duo/#pricing). -- [List of GitLab Duo features and their language models](../gitlab_duo/_index.md). +- [GitLab Duo features by add-on](../gitlab_duo/_index.md#summary-of-gitlab-duo-features). +- [How to purchase an add-on](../../subscriptions/subscription-add-ons.md). - [GitLab Duo Self-Hosted](../../administration/gitlab_duo_self_hosted/_index.md). -- [GitLab Duo features supported by GitLab Duo Self-Hosted](../../administration/gitlab_duo_self_hosted/_index.md#supported-gitlab-duo-features). - [Health check details](../gitlab_duo/setup.md#run-a-health-check-for-gitlab-duo). ## Step 2: Try GitLab Duo Chat in the UI -Confirm that Chat is available in the GitLab UI. +If your organization has either the GitLab Duo Pro or Enterprise add-on, +you can try using Chat in the GitLab UI. Go to a project and in the upper-right corner, a button named **GitLab Duo Chat** should be displayed. If this button is available, it means everything is configured properly. @@ -58,11 +52,15 @@ GitLab Duo is available in all stages of your workflow. From troubleshooting CI/CD pipelines to writing test cases and resolving security threats, GitLab Duo can help you in a variety of ways. -If you want to test a feature, you can go to one of your failed CI/CD jobs and at the bottom -of the page, select **Troubleshoot**. +The features you have access to differ depending on your subscription tier, add-on, and offering. -Or, in an issue that has a lot of comments, in the **Activity** section, select **View summary**. -GitLab Duo summarizes the contents of the issue. +For example, if you have access to: + +- Root Cause Analysis, you can go to one of your failed CI/CD jobs and at the bottom + of the page, select **Troubleshoot**. + +- Discussion Summary, in the **Activity** section of an issue with a lot of comments, + select **View summary**. GitLab Duo summarizes the contents of the issue. For more information, see: @@ -71,35 +69,36 @@ For more information, see: ## Step 4: Prepare to use GitLab Duo in your IDE -To use GitLab Duo, including Code Suggestions, in your IDE: +Now you can try GitLab Duo features, like GitLab Duo Chat and Code Suggestions, in your IDE. -- Install an extension in your local IDE. -- Authenticate with GitLab from the IDE. You can use either OAuth or a personal access token. +To use GitLab Duo Chat in your IDE, you'll install an extension and authenticate with GitLab. -Then you can confirm that GitLab Duo is available in your IDE and test some of the features. +- In GitLab 17.11 and earlier, you'll need the GitLab Duo Pro or Enterprise add-on. +- In 18.0 and later, you'll need to turn on GitLab Duo, + and have the GitLab Duo Core, Pro, or Enterprise add-on. + GitLab Duo Core is included with all Premium and Ultimate subscriptions. -Alternately, you can use the Web IDE, which is included in the GitLab UI and already fully configured. +Alternatively, if you have GitLab Duo Pro or Enterprise, you can use the Web IDE, +which is included in the GitLab UI and already fully configured. For more information, see: +- [Turn on GitLab Duo](../gitlab_duo/turn_on_off.md). - [Set up the extension for VS Code](../../editor_extensions/visual_studio_code/setup.md). - [Set up the extension for JetBrains](../../editor_extensions/jetbrains_ide/setup.md). - [Set up the extension for Visual Studio](../../editor_extensions/visual_studio/setup.md). - [Set up the extension for Neovim](../../editor_extensions/neovim/setup.md). - [Use the Web IDE](../project/web_ide/_index.md). -## Step 5: Confirm that Code Suggestions is on in your IDE +## Step 5: Start using Code Suggestions and Chat in your IDE -Finally, go to the settings for the extension and confirm that Code Suggestions is enabled, -as well as the languages you want suggestions for. - -You should also confirm that Chat is enabled. - -Then test Code Suggestions and Chat in your IDE. +Finally, test Code Suggestions and Chat in your IDE. - Code Suggestions recommends code as you type. - Chat is available to ask questions about your code or anything else you need. +You can choose the languages you want suggestions for. + For more information, see: - [Supported extensions and languages](../project/repository/code_suggestions/supported_extensions.md). diff --git a/doc/user/gitlab_duo/_index.md b/doc/user/gitlab_duo/_index.md index 20668efcd43..543603707ca 100644 --- a/doc/user/gitlab_duo/_index.md +++ b/doc/user/gitlab_duo/_index.md @@ -113,23 +113,48 @@ To improve your security, try these features: ## Summary of GitLab Duo features -| Feature | Tier | Add-on | GitLab.com | GitLab Self-Managed | GitLab Dedicated | GitLab Duo Self-Hosted | -| ------- | ---- | ------ | ---------- | ----------------- | ---------------- | ------------------------ | -| [GitLab Duo Chat](../gitlab_duo_chat/_index.md) | Premium, Ultimate | GitLab Duo Pro or Enterprise, GitLab Duo with Amazon Q | Generally available | Generally available | Generally available | Generally available | -| [GitLab Duo Workflow](../duo_workflow/_index.md) | Ultimate | None | Private beta | N/A | N/A | N/A | -| [Issue Description Generation](../project/issues/managing_issues.md#populate-an-issue-with-issue-description-generation) | Ultimate | GitLab Duo Enterprise | Experiment | N/A | N/A | N/A | -| [Discussion Summary](../discussions/_index.md#summarize-issue-discussions-with-duo-chat) | Ultimate | GitLab Duo Enterprise, GitLab Duo with Amazon Q | Generally available | Generally available | Generally available | Beta| -| [Code Suggestions](../project/repository/code_suggestions/_index.md) | Premium, Ultimate | GitLab Duo Pro or Enterprise, GitLab Duo with Amazon Q | Generally available | Generally available | Generally available | Generally available | -| [Code Explanation](../project/repository/code_explain.md) | Premium, Ultimate | GitLab Duo Pro or Enterprise, GitLab Duo with Amazon Q | Generally available | Generally available | Generally available | Generally available | -| [Test Generation](../gitlab_duo_chat/examples.md#write-tests-in-the-ide) | Premium, Ultimate | GitLab Duo Pro or Enterprise, GitLab Duo with Amazon Q | Generally available | Generally available | Generally available | Generally available | -| [Refactor Code](../gitlab_duo_chat/examples.md#refactor-code-in-the-ide) | Premium, Ultimate | GitLab Duo Pro or Enterprise, GitLab Duo with Amazon Q | Generally available | Generally available | Generally available | Generally available | -| [Fix Code](../gitlab_duo_chat/examples.md#fix-code-in-the-ide) | Premium, Ultimate | GitLab Duo Pro or Enterprise, GitLab Duo with Amazon Q | Generally available | Generally available | Generally available | Generally available | -| [GitLab Duo for the CLI](../../editor_extensions/gitlab_cli/_index.md#gitlab-duo-for-the-cli) | Ultimate | GitLab Duo Enterprise | Generally available | Generally available | Generally available | Beta| -| [Merge Request Summary](../project/merge_requests/duo_in_merge_requests.md#generate-a-description-by-summarizing-code-changes) | Ultimate | GitLab Duo Enterprise | Beta | Beta | N/A | Beta| -| [Code Review](../project/merge_requests/duo_in_merge_requests.md#have-gitlab-duo-review-your-code) | Ultimate | GitLab Duo Enterprise | Beta | Beta | Beta | N/A | -| [Code Review Summary](../project/merge_requests/duo_in_merge_requests.md#summarize-a-code-review) | Ultimate | GitLab Duo Enterprise | Experiment | Experiment | N/A | Experiment | -| [Merge Commit Message Generation](../project/merge_requests/duo_in_merge_requests.md#generate-a-merge-commit-message) | Ultimate | GitLab Duo Enterprise | Generally available | Generally available | Generally available | Beta | -| [Root Cause Analysis](../gitlab_duo_chat/examples.md#troubleshoot-failed-cicd-jobs-with-root-cause-analysis) | Ultimate | GitLab Duo Enterprise, GitLab Duo with Amazon Q | Generally available | Generally available | Generally available | Beta | -| [Vulnerability Explanation](../application_security/vulnerabilities/_index.md#explaining-a-vulnerability) | Ultimate | GitLab Duo Enterprise, GitLab Duo with Amazon Q | Generally available | Generally available | Generally available | Beta | -| [Vulnerability Resolution](../application_security/vulnerabilities/_index.md#vulnerability-resolution) | Ultimate | GitLab Duo Enterprise, GitLab Duo with Amazon Q | Generally available | Generally available | Generally available | Beta | -| [AI Impact Dashboard](../analytics/ai_impact_analytics.md) | Ultimate | GitLab Duo Enterprise | Generally available | Generally available | N/A | Beta | +The following features are generally available on GitLab.com, GitLab Self-Managed, and GitLab Dedicated. + +They require a Premium or Ultimate subscription and one of the available add-ons. + +| Feature | GitLab Duo Core | GitLab Duo Pro | GitLab Duo Enterprise | +|---------|----------|---------|----------------| +| [Code Suggestions](../project/repository/code_suggestions/_index.md) | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | +| [GitLab Duo Chat](../gitlab_duo_chat/_index.md) in IDEs | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | +| [Code Explanation](../gitlab_duo_chat/examples.md#explain-selected-code) in IDEs | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | +| [Test Generation](../gitlab_duo_chat/examples.md#write-tests-in-the-ide) in IDEs | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | +| [Refactor Code](../gitlab_duo_chat/examples.md#refactor-code-in-the-ide) in IDEs | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | +| [Fix Code](../gitlab_duo_chat/examples.md#fix-code-in-the-ide) in IDEs | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | +| [GitLab Duo Chat](../gitlab_duo_chat/_index.md) in GitLab UI | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | +| [Code Explanation](../project/repository/code_explain.md) in GitLab UI | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | +| [Test Generation](../gitlab_duo_chat/examples.md#write-tests-in-the-ide) in GitLab UI | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | +| [Refactor Code](../gitlab_duo_chat/examples.md#refactor-code-in-the-ide) in GitLab UI | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | +| [Fix Code](../gitlab_duo_chat/examples.md#fix-code-in-the-ide) in GitLab UI | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | {{< icon name="check-circle-filled" >}} Yes | +| [Discussion Summary](../discussions/_index.md#summarize-issue-discussions-with-duo-chat) | {{< icon name="dash-circle" >}} No | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | +| [GitLab Duo for the CLI](../../editor_extensions/gitlab_cli/_index.md#gitlab-duo-for-the-cli) | {{< icon name="dash-circle" >}} No | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | +| [Merge Commit Message Generation](../project/merge_requests/duo_in_merge_requests.md#generate-a-merge-commit-message) | {{< icon name="dash-circle" >}} No | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | +| [Root Cause Analysis](../gitlab_duo_chat/examples.md#troubleshoot-failed-cicd-jobs-with-root-cause-analysis) | {{< icon name="dash-circle" >}} No | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | +| [Vulnerability Explanation](../application_security/vulnerabilities/_index.md#explaining-a-vulnerability) | {{< icon name="dash-circle" >}} No | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | +| [Vulnerability Resolution](../application_security/vulnerabilities/_index.md#vulnerability-resolution) | {{< icon name="dash-circle" >}} No | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | +| [AI Impact Dashboard](../analytics/ai_impact_analytics.md) | {{< icon name="dash-circle" >}} No | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | + +In addition: + +- All GitLab Duo Core and Pro features include generally available support for + [GitLab Duo Self-Hosted](../../administration/gitlab_duo_self_hosted/_index.md). +- All GitLab Duo Enterprise-only features include beta support for GitLab Duo Self-Hosted. + +### Beta and experimental features + +The following features are not generally available. + +They require a Premium or Ultimate subscription and one of the available add-ons. + +| Feature | GitLab Duo Core | GitLab Duo Pro | GitLab Duo Enterprise | GitLab.com | GitLab Self-Managed | GitLab Dedicated | GitLab Duo Self-Hosted | +|---------|----------|---------|----------------|-----------|-------------|-----------|------------------------| +| [Code Review Summary](../project/merge_requests/duo_in_merge_requests.md#summarize-a-code-review) | {{< icon name="dash-circle" >}} No | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | Experiment | Experiment | {{< icon name="dash-circle" >}} No | Experiment | +| [Issue Description Generation](../project/issues/managing_issues.md#populate-an-issue-with-issue-description-generation) | {{< icon name="dash-circle" >}} No | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | Experiment | {{< icon name="dash-circle" >}} No | {{< icon name="dash-circle" >}} No | N/A | +| [Code Review](../project/merge_requests/duo_in_merge_requests.md#have-gitlab-duo-review-your-code) | {{< icon name="dash-circle" >}} No | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | Beta | Beta | Beta | N/A | +| [Merge Request Summary](../project/merge_requests/duo_in_merge_requests.md#generate-a-description-by-summarizing-code-changes) | {{< icon name="dash-circle" >}} No | {{< icon name="dash-circle" >}} No | {{< icon name="check-circle-filled" >}} Yes | Beta | Beta | {{< icon name="dash-circle" >}} No | Beta | + +[GitLab Duo Workflow](../duo_workflow/_index.md) is in private beta, does not require an add-on, and is not supported for GitLab Duo Self-Hosted. diff --git a/doc/user/gitlab_duo/turn_on_off.md b/doc/user/gitlab_duo/turn_on_off.md index 51c6a1b6dbd..c27ee4cfb6b 100644 --- a/doc/user/gitlab_duo/turn_on_off.md +++ b/doc/user/gitlab_duo/turn_on_off.md @@ -5,6 +5,65 @@ info: To determine the technical writer assigned to the Stage/Group associated w title: Control GitLab Duo availability --- +GitLab Duo availability depends on your subscription add-on: + +- [GitLab Duo Core](../../subscriptions/subscription-add-ons.md#gitlab-duo-core), or +- [GitLab Duo Pro or Enterprise](../../subscriptions/subscription-add-ons.md#gitlab-duo-pro-and-enterprise). + +Depending on your add-on, you can turn GitLab Duo on and off for a group, project, or instance. + +## Change GitLab Duo Core availability + +{{< history >}} + +- [Introduced](https://link-to-issue) in GitLab 18.0. + +{{< /history >}} + +If you have the GitLab Duo Core add-on, which is included with Premium and Ultimate subscriptions, +GitLab Duo Chat and Code Suggestions are available in your IDEs, and are turned on by default. + +If you were an existing user with a Premium or Ultimate subscription before May 15, 2025, +Chat and Code Suggestions in your IDEs are turned off by default. To turn on these +features: + +1. Upgrade to GitLab 18.0 or later. +1. Turn on the IDE features for your group or instance. + +### For a group + +On GitLab.com, you can turn GitLab Duo Core on or off for a top-level group, but not for a subgroup or project. + +Prerequisites: + +- You must have the Owner role for the top-level group. + +To turn GitLab Duo Core on or off for a top-level group on GitLab.com: + +1. On the left sidebar, select **Search or go to** and find your top-level group. +1. Select **Settings > GitLab Duo**. +1. Select **Change configuration**. +1. Below **GitLab Duo Core**, select or clear the **Turn on IDE features** checkbox. +1. Select **Save changes**. + +### For an instance + +On GitLab Self-Managed, you can turn GitLab Duo Core on or off for an instance. + +Prerequisites: + +- You must be an administrator. + +To turn GitLab Duo Core on or off for an instance: + +1. On the left sidebar, at the bottom, select **Admin area**. +1. Select **GitLab Duo**. +1. Select **Change configuration**. +1. Below **GitLab Duo Core**, select or clear the **Turn on IDE features** checkbox. +1. Select **Save changes**. + +## Change GitLab Duo Pro and Enterprise availability + {{< history >}} - [Settings to turn AI features on and off introduced](https://gitlab.com/groups/gitlab-org/-/epics/12404) in GitLab 16.10. @@ -12,20 +71,10 @@ title: Control GitLab Duo availability {{< /history >}} -GitLab Duo features that are generally available are automatically turned on for all users that have access. - -- You must have an [GitLab Duo Pro or Enterprise add-on subscription](../../subscriptions/subscription-add-ons.md). -- For some generally available features, like [Code Suggestions](../project/repository/code_suggestions/_index.md), - [you must also assign seats](../../subscriptions/subscription-add-ons.md#assign-gitlab-duo-seats) - to the users you want to have access. - -{{< alert type="note" >}} - -To turn on GitLab Duo Self-Hosted, see [Configure GitLab to access GitLab Duo Self-Hosted](../../administration/gitlab_duo_self_hosted/configure_duo_features.md). - -{{< /alert >}} - -## Turn GitLab Duo features on or off +For GitLab Duo Pro or Enterprise, GitLab Duo is turned on by default. +For some generally available features, like Code Suggestions, +[you must also assign seats](../../subscriptions/subscription-add-ons.md#assign-gitlab-duo-seats) +to the users you want to have access. You can turn GitLab Duo on or off for a group, project, or instance. diff --git a/doc/user/gitlab_duo_chat/turn_on_off.md b/doc/user/gitlab_duo_chat/turn_on_off.md index 74316ca7f64..0962f7f4280 100644 --- a/doc/user/gitlab_duo_chat/turn_on_off.md +++ b/doc/user/gitlab_duo_chat/turn_on_off.md @@ -81,7 +81,7 @@ In GitLab 16.8, 16.9, and 16.10, on GitLab Dedicated, GitLab Duo Chat is availab ## Turn off GitLab Duo Chat To limit the data that GitLab Duo Chat has access to, follow the instructions for -[turning off GitLab Duo features](../gitlab_duo/turn_on_off.md#turn-gitlab-duo-features-on-or-off). +[turning off GitLab Duo features](../gitlab_duo/turn_on_off.md). ## Turn off Chat in VS Code diff --git a/doc/user/project/repository/code_suggestions/set_up.md b/doc/user/project/repository/code_suggestions/set_up.md index 675aa767c6f..848d8a4585c 100644 --- a/doc/user/project/repository/code_suggestions/set_up.md +++ b/doc/user/project/repository/code_suggestions/set_up.md @@ -175,4 +175,4 @@ For more information, see the [JetBrains product documentation](https://www.jetb ### Turn off GitLab Duo -Alternatively, you can [turn off GitLab Duo](../../../gitlab_duo/turn_on_off.md#turn-gitlab-duo-features-on-or-off) (which includes Code Suggestions) completely for a group, project, or instance. +Alternatively, you can [turn off GitLab Duo](../../../gitlab_duo/turn_on_off.md) (which includes Code Suggestions) completely for a group, project, or instance. diff --git a/lib/gitlab/background_migration/delete_twitter_identities.rb b/lib/gitlab/background_migration/delete_twitter_identities.rb new file mode 100644 index 00000000000..5e46ba72772 --- /dev/null +++ b/lib/gitlab/background_migration/delete_twitter_identities.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + class DeleteTwitterIdentities < BatchedMigrationJob + feature_category :system_access + operation_name :delete_twitter_identities + + def perform + each_sub_batch do |sub_batch| + sub_batch.where(provider: 'twitter').delete_all + end + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 80b02e9d69d..c5902452121 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -36198,9 +36198,6 @@ msgstr[1] "" msgid "Line changes" msgstr "" -msgid "Line number %{number}" -msgstr "" - msgid "Link" msgstr "" @@ -49852,6 +49849,9 @@ msgstr "" msgid "Random" msgstr "" +msgid "RapidDiffs|Added line %d" +msgstr "" + msgid "RapidDiffs|Diff line" msgstr "" @@ -49861,12 +49861,18 @@ msgstr "" msgid "RapidDiffs|Failed to expand lines, please try again." msgstr "" +msgid "RapidDiffs|Line %d" +msgstr "" + msgid "RapidDiffs|Original line" msgstr "" msgid "RapidDiffs|Original line number" msgstr "" +msgid "RapidDiffs|Removed line %d" +msgstr "" + msgid "RapidDiffs|Show hidden lines" msgstr "" diff --git a/scripts/lint/unused_helper_methods.rb b/scripts/lint/unused_helper_methods.rb index cb448c309bd..c90e0231753 100755 --- a/scripts/lint/unused_helper_methods.rb +++ b/scripts/lint/unused_helper_methods.rb @@ -6,8 +6,10 @@ require 'parallel' require 'rainbow' +require 'yaml' -UNUSED_METHODS = 52 +UNUSED_METHODS = 49 +EXCLUDED_METHODS_PATH = '.gitlab/lint/unused_helper_methods/exluded_methods.yml' print_output = %w[true 1].include? ENV["REPORT_ALL_UNUSED_METHODS"] @@ -35,6 +37,14 @@ helpers = source_files.keys.grep(%r{app/helpers}).flat_map do |filename| end end +# Remove any excluded methods +# +excluded_methods = YAML.load_file(EXCLUDED_METHODS_PATH, symbolize_names: true) + +helpers.reject! do |h| + excluded_methods.dig(h[:method].to_sym, :file) == h[:file] +end + puts "Scanning #{source_files.size} files for #{helpers.size} helpers..." if print_output # Combine all the source code into one big string, because regex are fast. diff --git a/spec/components/rapid_diffs/app_component_spec.rb b/spec/components/rapid_diffs/app_component_spec.rb index 58156224af7..21868651e2d 100644 --- a/spec/components/rapid_diffs/app_component_spec.rb +++ b/spec/components/rapid_diffs/app_component_spec.rb @@ -14,6 +14,11 @@ RSpec.describe RapidDiffs::AppComponent, type: :component, feature_category: :co let(:should_sort_metadata_files) { false } let(:lazy) { false } + it "renders app" do + render_component + expect(page).to have_css('[data-rapid-diffs]') + end + it "renders diffs slice" do render_component expect(page.all('diff-file').size).to eq(2) @@ -22,29 +27,29 @@ RSpec.describe RapidDiffs::AppComponent, type: :component, feature_category: :co it "renders app data" do render_component app = page.find('[data-rapid-diffs]') - expect(app).not_to be_nil - expect(app['data-reload-stream-url']).to eq(reload_stream_url) - expect(app['data-diffs-stats-endpoint']).to eq(diffs_stats_endpoint) - expect(app['data-diff-files-endpoint']).to eq(diff_files_endpoint) + data = Gitlab::Json.parse(app['data-app-data']) + expect(data['reload_stream_url']).to eq(reload_stream_url) + expect(data['diffs_stats_endpoint']).to eq(diffs_stats_endpoint) + expect(data['diff_files_endpoint']).to eq(diff_files_endpoint) + expect(data['show_whitespace']).to eq(show_whitespace) + expect(data['diff_view_type']).to eq(diff_view) + expect(data['update_user_endpoint']).to eq(update_user_endpoint) + expect(data['diffs_stream_url']).to eq(stream_url) end context "with should_sort_metadata_files set to true" do let(:should_sort_metadata_files) { true } - it "renders should_sort_metadata_files" do + it "enables sorting metadata" do render_component app = page.find('[data-rapid-diffs]') - expect(app['data-should-sort-metadata-files']).to eq('true') + expect(Gitlab::Json.parse(app['data-app-data'])['should_sort_metadata_files']).to eq(should_sort_metadata_files) end end it "renders view settings" do render_component - settings = page.find('[data-view-settings]') - expect(settings).not_to be_nil - expect(settings['data-show-whitespace']).to eq('true') - expect(settings['data-diff-view-type']).to eq(diff_view) - expect(settings['data-update-user-endpoint']).to eq(update_user_endpoint) + expect(page).to have_css('[data-view-settings]') end it "renders file browser toggle" do @@ -82,9 +87,7 @@ RSpec.describe RapidDiffs::AppComponent, type: :component, feature_category: :co it "renders stream container" do render_component - container = page.find("#js-stream-container") - expect(container).not_to be_nil - expect(container['data-diffs-stream-url']).to eq(stream_url) + expect(page).to have_css("[data-stream-remaining-diffs]") end it "renders diffs_list slot" do diff --git a/spec/components/rapid_diffs/shared.rb b/spec/components/rapid_diffs/shared.rb index 13e488df8dd..ac98d83690d 100644 --- a/spec/components/rapid_diffs/shared.rb +++ b/spec/components/rapid_diffs/shared.rb @@ -29,7 +29,7 @@ RSpec.shared_context "with diff file component tests" do it "renders server data" do render_component diff_path = "/#{namespace.to_param}/#{project.to_param}/-/blob/#{diff_file.content_sha}/#{diff_file.file_path}" - expect(web_component['data-diff-lines-path']).to eq("#{diff_path}/diff_lines") + expect(file_data['diff_lines_path']).to eq("#{diff_path}/diff_lines") end it "renders line count" do @@ -55,18 +55,18 @@ RSpec.shared_context "with diff file component tests" do it "renders no preview" do render_component - expect(web_component['data-viewer']).to eq('no_preview') + expect(file_data['viewer']).to eq('no_preview') end end it "renders inline text viewer" do render_component - expect(web_component['data-viewer']).to eq('text_inline') + expect(file_data['viewer']).to eq('text_inline') end it "renders parallel text viewer" do render_component(parallel_view: true) - expect(web_component['data-viewer']).to eq('text_parallel') + expect(file_data['viewer']).to eq('text_parallel') end end @@ -77,7 +77,7 @@ RSpec.shared_context "with diff file component tests" do it "renders no preview" do render_component - expect(web_component['data-viewer']).to eq('no_preview') + expect(file_data['viewer']).to eq('no_preview') end it "disables virtual rendering" do @@ -93,7 +93,7 @@ RSpec.shared_context "with diff file component tests" do it "renders no preview" do render_component - expect(web_component['data-viewer']).to eq('no_preview') + expect(file_data['viewer']).to eq('no_preview') end it "disables virtual rendering" do @@ -101,4 +101,8 @@ RSpec.shared_context "with diff file component tests" do expect(web_component).not_to have_css("[data-virtual]") end end + + def file_data + Gitlab::Json.parse(web_component['data-file-data']) + end end diff --git a/spec/components/rapid_diffs/viewers/text/line_number_component_spec.rb b/spec/components/rapid_diffs/viewers/text/line_number_component_spec.rb index 2815ca290fc..f74def24a76 100644 --- a/spec/components/rapid_diffs/viewers/text/line_number_component_spec.rb +++ b/spec/components/rapid_diffs/viewers/text/line_number_component_spec.rb @@ -31,6 +31,7 @@ RSpec.describe RapidDiffs::Viewers::Text::LineNumberComponent, type: :component, render_component(line: old_line, position: :old) expect(link.text).to eq('') expect(link[:'data-line-number']).to eq(old_line.old_pos.to_s) + expect(link[:'aria-label']).to eq("Removed line #{old_line.old_pos}") expect(td[:id]).to eq(old_line.id(diff_file.file_hash, :old)) expect(td[:'data-legacy-id']).to eq(diff_file.line_code(old_line)) expect(page).to have_selector('[data-position="old"]') @@ -40,6 +41,7 @@ RSpec.describe RapidDiffs::Viewers::Text::LineNumberComponent, type: :component, render_component(line: new_line, position: :new) expect(link.text).to eq('') expect(link[:'data-line-number']).to eq(new_line.new_pos.to_s) + expect(link[:'aria-label']).to eq("Added line #{old_line.new_pos}") expect(td[:id]).to eq(new_line.id(diff_file.file_hash, :new)) expect(td[:'data-legacy-id']).to eq(diff_file.line_code(new_line)) expect(page).to have_selector('[data-position="new"]') diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index 1321ec32aba..627732dbcdb 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -80,7 +80,6 @@ RSpec.describe 'Database schema', ci_pipeline_messages: %w[partition_id project_id], ci_pipeline_metadata: %w[partition_id], ci_pipeline_schedule_variables: %w[project_id], - ci_pipeline_variables: %w[partition_id pipeline_id project_id], ci_pipelines_config: %w[partition_id project_id], ci_pipelines: %w[partition_id auto_canceled_by_partition_id project_id user_id merge_request_id trigger_id], # LFKs are defined on the routing table ci_secure_file_states: %w[project_id], diff --git a/spec/dot_gitlab_ci/rules_spec.rb b/spec/dot_gitlab_ci/rules_spec.rb index 9e11c303986..4006ced2bec 100644 --- a/spec/dot_gitlab_ci/rules_spec.rb +++ b/spec/dot_gitlab_ci/rules_spec.rb @@ -7,7 +7,7 @@ require('fast_spec_helper') # NOTE: Do not remove the parentheses from this requ PatternsList = Struct.new(:name, :patterns) -RSpec.describe '.gitlab/ci/rules.gitlab-ci.yml', feature_category: :tooling do +RSpec.describe '.gitlab/ci/rules.gitlab-ci.yml', :unlimited_max_formatted_output_length, feature_category: :tooling do config = YAML.safe_load_file( File.expand_path('../../.gitlab/ci/rules.gitlab-ci.yml', __dir__), aliases: true @@ -192,6 +192,7 @@ RSpec.describe '.gitlab/ci/rules.gitlab-ci.yml', feature_category: :tooling do '.gitlab/agents/review-apps/config.yaml', '.gitlab/changelog_config.yml', '.gitlab/CODEOWNERS', + '.gitlab/lint/unused_helper_methods/exluded_methods.yml', '.gitleaksignore', '.gitpod.yml', '.graphqlrc', diff --git a/spec/frontend/lib/utils/object_utils_spec.js b/spec/frontend/lib/utils/object_utils_spec.js new file mode 100644 index 00000000000..e4a9177bee3 --- /dev/null +++ b/spec/frontend/lib/utils/object_utils_spec.js @@ -0,0 +1,16 @@ +import { camelizeKeys, transformKeys } from '~/lib/utils/object_utils'; + +describe('Object utils', () => { + describe('#transformKeys', () => { + it('transforms object keys', () => { + const transformer = (key) => `${key}1`; + expect(transformKeys({ foo: 1 }, transformer)).toStrictEqual({ foo1: 1 }); + }); + }); + + describe('#camelizeKeys', () => { + it('transforms object keys to camelCase', () => { + expect(camelizeKeys({ foo_bar: 1 })).toStrictEqual({ fooBar: 1 }); + }); + }); +}); diff --git a/spec/frontend/rapid_diffs/app/app_spec.js b/spec/frontend/rapid_diffs/app/app_spec.js index 64df1214d46..32ba9b984d8 100644 --- a/spec/frontend/rapid_diffs/app/app_spec.js +++ b/spec/frontend/rapid_diffs/app/app_spec.js @@ -3,7 +3,6 @@ import { setHTMLFixture } from 'helpers/fixtures'; import { createRapidDiffsApp } from '~/rapid_diffs/app'; import { initViewSettings } from '~/rapid_diffs/app/view_settings'; import { DiffFile } from '~/rapid_diffs/diff_file'; -import { DiffFileMounted } from '~/rapid_diffs/diff_file_mounted'; import { useDiffsList } from '~/rapid_diffs/stores/diffs_list'; import { pinia } from '~/pinia/instance'; import { initHiddenFilesWarning } from '~/rapid_diffs/app/init_hidden_files_warning'; @@ -12,6 +11,7 @@ import { StreamingError } from '~/rapid_diffs/streaming_error'; import { useDiffsView } from '~/rapid_diffs/stores/diffs_view'; import { fixWebComponentsStreamingOnSafari } from '~/rapid_diffs/app/safari_fix'; import { DIFF_FILE_MOUNTED } from '~/rapid_diffs/dom_events'; +import { disableContentVisibilityOnOlderChrome } from '~/rapid_diffs/app/chrome_fix'; jest.mock('~/lib/graphql'); jest.mock('~/awards_handler'); @@ -20,12 +20,36 @@ jest.mock('~/rapid_diffs/app/view_settings'); jest.mock('~/rapid_diffs/app/init_hidden_files_warning'); jest.mock('~/rapid_diffs/app/init_file_browser'); jest.mock('~/rapid_diffs/app/safari_fix'); +jest.mock('~/rapid_diffs/app/chrome_fix'); describe('Rapid Diffs App', () => { let app; - const createApp = (options) => { - app = createRapidDiffsApp(options); + const appData = { + diffsStreamUrl: '/stream', + reloadStreamUrl: '/reload', + diffsStatsEndpoint: '/stats', + diffFilesEndpoint: '/diff-files-metadata', + shouldSortMetadataFiles: true, + }; + const getHiddenFilesWarningTarget = () => document.querySelector('[data-hidden-files-warning]'); + + const createApp = (data = {}) => { + setHTMLFixture( + ` +
+
+
+
+
+
+
+ `, + ); + app = createRapidDiffsApp(); }; beforeAll(() => { @@ -39,19 +63,6 @@ describe('Rapid Diffs App', () => { createTestingPinia(); useDiffsView().loadDiffsStats.mockResolvedValue(); initFileBrowser.mockResolvedValue(); - setHTMLFixture( - ` -
-
-
- `, - ); }); it('initializes the app', () => { @@ -59,20 +70,49 @@ describe('Rapid Diffs App', () => { app.init(); expect(useDiffsView().diffsStatsEndpoint).toBe('/stats'); expect(useDiffsView().loadDiffsStats).toHaveBeenCalled(); - expect(initViewSettings).toHaveBeenCalledWith({ pinia, streamUrl: '/reload' }); + expect(initViewSettings).toHaveBeenCalledWith({ + pinia, + appData: app.appData, + target: document.querySelector('[data-view-settings]'), + }); expect(window.customElements.define).toHaveBeenCalledWith('diff-file', DiffFile); - expect(window.customElements.define).toHaveBeenCalledWith('diff-file-mounted', DiffFileMounted); + expect(window.customElements.define).toHaveBeenCalledWith( + 'diff-file-mounted', + expect.any(Function), + ); expect(window.customElements.define).toHaveBeenCalledWith('streaming-error', StreamingError); - expect(initHiddenFilesWarning).toHaveBeenCalled(); + expect(initHiddenFilesWarning).toHaveBeenCalledWith(getHiddenFilesWarningTarget()); expect(fixWebComponentsStreamingOnSafari).toHaveBeenCalled(); - expect(initFileBrowser).toHaveBeenCalledWith('/diff-files-metadata', true); + expect(disableContentVisibilityOnOlderChrome).toHaveBeenCalled(); + expect(initFileBrowser).toHaveBeenCalledWith({ + toggleTarget: document.querySelector('[data-file-browser-toggle]'), + browserTarget: document.querySelector('[data-file-browser]'), + appData: app.appData, + }); }); it('streams remaining diffs', () => { createApp(); app.init(); app.streamRemainingDiffs(); - expect(useDiffsList().streamRemainingDiffs).toHaveBeenCalledWith('/stream'); + expect(useDiffsList().streamRemainingDiffs).toHaveBeenCalledWith( + '/stream', + document.querySelector('[data-stream-remaining-diffs]'), + undefined, + ); + }); + + it('streams preloaded remaining diffs', () => { + const preload = {}; + window.gl.rapidDiffsPreload = preload; + createApp(); + app.init(); + app.streamRemainingDiffs(); + expect(useDiffsList().streamRemainingDiffs).toHaveBeenCalledWith( + '/stream', + document.querySelector('[data-stream-remaining-diffs]'), + preload, + ); }); it('reloads diff files', () => { @@ -92,26 +132,13 @@ describe('Rapid Diffs App', () => { it('reacts to files loading', () => { createApp(); app.init(); - document.dispatchEvent(new CustomEvent(DIFF_FILE_MOUNTED)); + document.querySelector('[data-rapid-diffs]').dispatchEvent(new CustomEvent(DIFF_FILE_MOUNTED)); expect(useDiffsList(pinia).addLoadedFile).toHaveBeenCalled(); }); it('skips sorting', () => { - setHTMLFixture( - ` -
-
-
- `, - ); - createApp(); + createApp({ shouldSortMetadataFiles: false }); app.init(); - expect(initFileBrowser).toHaveBeenCalledWith('/diff-files-metadata', false); + expect(app.appData.shouldSortMetadataFiles).toBe(false); }); }); diff --git a/spec/frontend/rapid_diffs/app/init_file_browser_spec.js b/spec/frontend/rapid_diffs/app/init_file_browser_spec.js index 61ef5ab769a..4708a64c51c 100644 --- a/spec/frontend/rapid_diffs/app/init_file_browser_spec.js +++ b/spec/frontend/rapid_diffs/app/init_file_browser_spec.js @@ -37,7 +37,12 @@ jest.mock('~/diffs/components/file_browser_toggle.vue', () => ({ })); describe('Init file browser', () => { - const diffFilesEndpoint = '/diff-files-metadata'; + let mockAxios; + let commit; + let appData; + + const getFileBrowserTarget = () => document.querySelector('[data-file-browser]'); + const getFileBrowserToggleTarget = () => document.querySelector('[data-file-browser-toggle]'); const getFileBrowser = () => document.querySelector('[data-file-browser-component]'); const createDiffFiles = () => [ { @@ -65,21 +70,41 @@ describe('Init file browser', () => { file_hash: '12dc3d87e90313d83a236a944f8a4869f1dc97e2', }, ]; - let mockAxios; - let commit; + + const initAppData = ({ + diffFilesEndpoint = '/diff-files-metadata', + shouldSortMetadataFiles = true, + } = {}) => { + appData = { + diffFilesEndpoint, + shouldSortMetadataFiles, + }; + }; + + const init = () => { + return initFileBrowser({ + toggleTarget: getFileBrowserToggleTarget(), + browserTarget: getFileBrowserTarget(), + appData, + }); + }; beforeEach(() => { + initAppData(); window.mrTabs = { eventHub: createEventHub() }; mockAxios = new MockAdapter(axios); - mockAxios.onGet(diffFilesEndpoint).reply(HTTP_STATUS_OK, { diff_files: createDiffFiles() }); + mockAxios + .onGet(appData.diffFilesEndpoint) + .reply(HTTP_STATUS_OK, { diff_files: createDiffFiles() }); commit = jest.spyOn(store, 'commit'); setHTMLFixture( `
- +
`, ); + DiffFile.getAll().forEach((file) => file.mount({ adapterConfig: {}, appData: {} })); }); beforeAll(() => { @@ -91,12 +116,12 @@ describe('Init file browser', () => { }); it('mounts the component', async () => { - await initFileBrowser(diffFilesEndpoint); + await init(); expect(getFileBrowser()).not.toBe(null); }); it('loads diff files data', async () => { - await initFileBrowser(diffFilesEndpoint); + await init(); expect(commit).toHaveBeenCalledWith( `diffs/${SET_TREE_DATA}`, expect.objectContaining({ @@ -109,7 +134,7 @@ describe('Init file browser', () => { it('handles file clicks', async () => { const selectFile = jest.fn(); const spy = jest.spyOn(DiffFile, 'findByFileHash').mockReturnValue({ selectFile }); - initFileBrowser(diffFilesEndpoint); + init(); await waitForPromises(); getFileBrowser().click(); expect(spy).toHaveBeenCalledWith('first'); @@ -117,13 +142,14 @@ describe('Init file browser', () => { }); it('shows file browser toggle', async () => { - initFileBrowser(diffFilesEndpoint); + init(); await waitForPromises(); expect(document.querySelector('[data-file-browser-toggle-component]')).not.toBe(null); }); it('disables sorting', async () => { - initFileBrowser(diffFilesEndpoint, false); + initAppData({ shouldSortMetadataFiles: false }); + init(); await waitForPromises(); expect(document.querySelector('[data-group-blobs-list-items="false"]')).not.toBe(null); }); diff --git a/spec/frontend/rapid_diffs/app/view_settings_spec.js b/spec/frontend/rapid_diffs/app/view_settings_spec.js index 7da423214b5..27702ea0385 100644 --- a/spec/frontend/rapid_diffs/app/view_settings_spec.js +++ b/spec/frontend/rapid_diffs/app/view_settings_spec.js @@ -8,8 +8,6 @@ import { useDiffsList } from '~/rapid_diffs/stores/diffs_list'; import { DiffFile } from '~/rapid_diffs/diff_file'; import { COLLAPSE_FILE, EXPAND_FILE } from '~/rapid_diffs/events'; -const streamUrl = '/stream'; - jest.mock('~/diffs/components/diff_app_controls.vue', () => ({ props: jest.requireActual('~/diffs/components/diff_app_controls.vue').default.props, render(h) { @@ -36,11 +34,21 @@ Vue.use(PiniaVuePlugin); describe('View settings', () => { let pinia; + let appData; const getDiffAppControls = () => document.querySelector('[data-diff-app-controls]'); const getVueInstance = () => getDiffAppControls().getInstance(); + const init = () => { + initViewSettings({ pinia, target: document.querySelector('[data-view-settings]'), appData }); + }; + beforeEach(() => { + appData = { + showWhitespace: true, + diffViewType: 'parallel', + updateUserEndpoint: '/update-user-endpoint', + }; setHTMLFixture(`
{ }); it('sets initial state', () => { - initViewSettings({ pinia, streamUrl }); + init(); expect(useDiffsView().viewType).toBe('parallel'); expect(useDiffsView().showWhitespace).toBe(true); expect(useDiffsView().updateUserEndpoint).toBe('/update-user-endpoint'); - expect(useDiffsView().streamUrl).toBe(streamUrl); }); it('sets loaded files', () => { - initViewSettings({ pinia, streamUrl }); + init(); expect(useDiffsList().fillInLoadedFiles).toHaveBeenCalled(); }); it('renders diff app controls', () => { - initViewSettings({ pinia, streamUrl }); + init(); expect(getDiffAppControls()).not.toBe(null); }); @@ -77,7 +84,7 @@ describe('View settings', () => { removedLines: 2, diffsCount: 3, }; - initViewSettings({ pinia, streamUrl }); + init(); const el = getDiffAppControls(); const getProp = (prop) => JSON.parse(el.dataset[prop]); expect(getProp('hasChanges')).toBe(true); @@ -93,7 +100,7 @@ describe('View settings', () => { it('triggers collapse all files', () => { const trigger = jest.fn(); jest.spyOn(DiffFile, 'getAll').mockReturnValue([{ trigger }]); - initViewSettings({ pinia, streamUrl }); + init(); getVueInstance().$emit('collapseAllFiles'); expect(trigger).toHaveBeenCalledWith(COLLAPSE_FILE); }); @@ -101,19 +108,19 @@ describe('View settings', () => { it('triggers expand all files', () => { const trigger = jest.fn(); jest.spyOn(DiffFile, 'getAll').mockReturnValue([{ trigger }]); - initViewSettings({ pinia, streamUrl }); + init(); getVueInstance().$emit('expandAllFiles'); expect(trigger).toHaveBeenCalledWith(EXPAND_FILE); }); it('updates view type', async () => { - initViewSettings({ pinia, streamUrl }); + init(); await getVueInstance().$emit('updateDiffViewType', 'inline'); expect(useDiffsView().updateViewType).toHaveBeenCalledWith('inline'); }); it('toggles whitespace', async () => { - initViewSettings({ pinia, streamUrl }); + init(); await getVueInstance().$emit('toggleWhitespace', false); expect(useDiffsView().updateShowWhitespace).toHaveBeenCalledWith(false); }); diff --git a/spec/frontend/rapid_diffs/diff_file_spec.js b/spec/frontend/rapid_diffs/diff_file_spec.js index ce482aaa931..1c2155e46ff 100644 --- a/spec/frontend/rapid_diffs/diff_file_spec.js +++ b/spec/frontend/rapid_diffs/diff_file_spec.js @@ -13,18 +13,21 @@ jest.mock('~/rapid_diffs/intersection_observer', () => { } } Observer.prototype.observe = jest.fn(); + Observer.prototype.unobserve = jest.fn(); return Observer; }); describe('DiffFile Web Component', () => { + const fileData = JSON.stringify({ viewer: 'current', custom: 'bar' }); const html = ` - +
`; - let adapter; + let app; + let defaultAdapter; const getDiffElement = () => document.querySelector('[id=foo]'); const getWebComponentElement = () => document.querySelector('diff-file'); @@ -32,16 +35,28 @@ describe('DiffFile Web Component', () => { const triggerVisibility = (isIntersecting) => trigger([{ isIntersecting, target: getWebComponentElement() }]); - const assignAdapter = (customAdapter) => { - adapter = customAdapter; - getWebComponentElement().adapterConfig = { current: [customAdapter] }; + const createDefaultAdapter = (customAdapter) => { + defaultAdapter = customAdapter; + }; + + const initRapidDiffsApp = (adapterConfig = { current: [defaultAdapter] }, appData = {}) => { + app = { + adapterConfig, + appData, + }; + }; + + const mount = () => { + document.body.innerHTML = html; + getWebComponentElement().mount(app); }; const getContext = () => ({ + appData: app.appData, diffElement: getDiffElement(), - viewer: 'current', data: { custom: 'bar', + viewer: 'current', }, sink: {}, trigger: getWebComponentElement().trigger, @@ -52,8 +67,7 @@ describe('DiffFile Web Component', () => { }); beforeEach(() => { - document.body.innerHTML = html; - assignAdapter({ + createDefaultAdapter({ click: jest.fn(), clicks: { foo: jest.fn(), @@ -62,10 +76,11 @@ describe('DiffFile Web Component', () => { invisible: jest.fn(), mounted: jest.fn(), }); + initRapidDiffsApp(); }); it('observes diff element', () => { - getWebComponentElement().mount(); + mount(); expect(IS.prototype.observe).toHaveBeenCalledWith(getWebComponentElement()); }); @@ -74,14 +89,14 @@ describe('DiffFile Web Component', () => { document.addEventListener(DIFF_FILE_MOUNTED, () => { emitted = true; }); - getWebComponentElement().mount(); - expect(adapter.mounted).toHaveBeenCalled(); - expect(adapter.mounted.mock.instances[0]).toStrictEqual(getContext()); + mount(); + expect(defaultAdapter.mounted).toHaveBeenCalled(); + expect(defaultAdapter.mounted.mock.instances[0]).toStrictEqual(getContext()); expect(emitted).toBe(true); }); it('#selectFile', () => { - getWebComponentElement().mount(); + mount(); const spy = jest.spyOn(getWebComponentElement(), 'scrollIntoView'); getWebComponentElement().selectFile(); expect(spy).toHaveBeenCalled(); @@ -89,34 +104,34 @@ describe('DiffFile Web Component', () => { describe('when visible', () => { beforeEach(() => { - getWebComponentElement().mount(); + mount(); }); it('handles all clicks', () => { triggerVisibility(true); getDiffElement().click(); - expect(adapter.click).toHaveBeenCalledWith(expect.any(MouseEvent)); - expect(adapter.click.mock.instances[0]).toStrictEqual(getContext()); + expect(defaultAdapter.click).toHaveBeenCalledWith(expect.any(MouseEvent)); + expect(defaultAdapter.click.mock.instances[0]).toStrictEqual(getContext()); }); it('handles specific clicks', () => { triggerVisibility(true); const clickTarget = getDiffElement().querySelector('[data-click=foo]'); clickTarget.click(); - expect(adapter.clicks.foo).toHaveBeenCalledWith(expect.any(MouseEvent), clickTarget); - expect(adapter.clicks.foo.mock.instances[0]).toStrictEqual(getContext()); + expect(defaultAdapter.clicks.foo).toHaveBeenCalledWith(expect.any(MouseEvent), clickTarget); + expect(defaultAdapter.clicks.foo.mock.instances[0]).toStrictEqual(getContext()); }); it('handles visible event', () => { triggerVisibility(true); - expect(adapter.visible).toHaveBeenCalled(); - expect(adapter.visible.mock.instances[0]).toStrictEqual(getContext()); + expect(defaultAdapter.visible).toHaveBeenCalled(); + expect(defaultAdapter.visible.mock.instances[0]).toStrictEqual(getContext()); }); it('handles invisible event', () => { triggerVisibility(false); - expect(adapter.invisible).toHaveBeenCalled(); - expect(adapter.invisible.mock.instances[0]).toStrictEqual(getContext()); + expect(defaultAdapter.invisible).toHaveBeenCalled(); + expect(defaultAdapter.invisible.mock.instances[0]).toStrictEqual(getContext()); }); }); @@ -126,10 +141,15 @@ describe('DiffFile Web Component', () => { }); it('#getAll', () => { - document.body.innerHTML = ``; + document.body.innerHTML = ` +
+
+ `; const instances = DiffFile.getAll(); expect(instances.length).toBe(2); instances.forEach((instance) => expect(instance).toBeInstanceOf(DiffFile)); + // properly run destruction callbacks + instances.forEach((instance) => instance.mount(app)); }); }); }); diff --git a/spec/frontend/rapid_diffs/expand_lines/adapter_spec.js b/spec/frontend/rapid_diffs/expand_lines/adapter_spec.js index 2f9c4e1b0cd..7a25a2f2ec8 100644 --- a/spec/frontend/rapid_diffs/expand_lines/adapter_spec.js +++ b/spec/frontend/rapid_diffs/expand_lines/adapter_spec.js @@ -17,7 +17,7 @@ describe('ExpandLinesAdapter', () => { return [prev ? new DiffLineRow(prev) : null, next ? new DiffLineRow(next) : null]; }; const getDiffFileContext = () => { - return { data: { diffLinesPath: '/lines' }, viewer: 'text_parallel' }; + return { data: { diffLinesPath: '/lines', viewer: 'text_parallel' } }; }; const click = (direction) => { return ExpandLinesAdapter.clicks.expandLines.call( diff --git a/spec/frontend/rapid_diffs/options_menu/adapter_spec.js b/spec/frontend/rapid_diffs/options_menu/adapter_spec.js index 506331393c9..f024c47ad53 100644 --- a/spec/frontend/rapid_diffs/options_menu/adapter_spec.js +++ b/spec/frontend/rapid_diffs/options_menu/adapter_spec.js @@ -3,23 +3,6 @@ import { OptionsMenuAdapter } from '~/rapid_diffs/options_menu/adapter'; describe('Diff File Options Menu', () => { const item1 = { text: 'item 1', path: 'item/1/path' }; - const html = ` - -
-
-
-
- - -
-
-
-
- - - `; function get(element) { const elements = { @@ -34,18 +17,33 @@ describe('Diff File Options Menu', () => { return elements[element]?.(); } - function assignAdapter(customAdapter) { - get('file').adapterConfig = { any: [customAdapter] }; - } + const mount = () => { + const viewer = 'any'; + document.body.innerHTML = ` + +
+
+
+
+ + +
+
+
+
+ + `; + get('file').mount({ adapterConfig: { [viewer]: [OptionsMenuAdapter] }, appData: {} }); + }; beforeAll(() => { customElements.define('diff-file', DiffFile); }); beforeEach(() => { - document.body.innerHTML = html; - assignAdapter(OptionsMenuAdapter); - get('file').mount(); + mount(); }); it('starts with the server-rendered button', () => { diff --git a/spec/frontend/rapid_diffs/stores/diffs_list_spec.js b/spec/frontend/rapid_diffs/stores/diffs_list_spec.js index 39fd9c3e05c..5118c7fa758 100644 --- a/spec/frontend/rapid_diffs/stores/diffs_list_spec.js +++ b/spec/frontend/rapid_diffs/stores/diffs_list_spec.js @@ -73,7 +73,7 @@ describe('Diffs list store', () => { describe('#streamRemainingDiffs', () => { it('streams request', async () => { const url = '/stream'; - store.streamRemainingDiffs(url); + store.streamRemainingDiffs(url, findStreamContainer()); const { signal } = store.loadingController; await waitForPromises(); expect(global.fetch).toHaveBeenCalledWith(url, { signal }); @@ -86,15 +86,14 @@ describe('Diffs list store', () => { const body = {}; const signal = {}; const streamRequest = Promise.resolve({ body }); - window.gl.rapidDiffsPreload = { controller: { signal }, streamRequest }; + const preload = { controller: { signal }, streamRequest }; const url = '/stream'; - store.streamRemainingDiffs(url); + store.streamRemainingDiffs(url, findStreamContainer(), preload); await waitForPromises(); expect(global.fetch).not.toHaveBeenCalled(); expect(renderHtmlStreams).toHaveBeenCalledWith([body], findStreamContainer(), { signal, }); - window.gl.rapidDiffsPreload = undefined; }); it('measures performance', async () => { diff --git a/spec/frontend/rapid_diffs/toggle_file/adapter_spec.js b/spec/frontend/rapid_diffs/toggle_file/adapter_spec.js index 9cdc78a05bf..8fa324b0b80 100644 --- a/spec/frontend/rapid_diffs/toggle_file/adapter_spec.js +++ b/spec/frontend/rapid_diffs/toggle_file/adapter_spec.js @@ -3,28 +3,6 @@ import { ToggleFileAdapter } from '~/rapid_diffs/toggle_file/adapter'; import { COLLAPSE_FILE, EXPAND_FILE } from '~/rapid_diffs/events'; describe('Diff File Toggle Behavior', () => { - // In our version of Jest/JSDOM we cannot use - // - // - CSS "&" nesting (baseline 2023) - // - Element.checkVisibility (baseline 2024) - // - :has (baseline 2023) - // - // so this cannot test CSS (which is a majority of our behavior), and must assume that - // browser CSS is working as documented when we tweak HTML attributes - const html = ` - -
-
-
< - - -
-
-
- - - `; - function get(element) { const elements = { file: () => document.querySelector('diff-file'), @@ -36,18 +14,29 @@ describe('Diff File Toggle Behavior', () => { return elements[element]?.(); } - function assignAdapter(customAdapter) { - get('file').adapterConfig = { any: [customAdapter] }; - } + const mount = () => { + const viewer = 'any'; + document.body.innerHTML = ` + +
+
+
< + + +
+
+
+ + `; + get('file').mount({ adapterConfig: { [viewer]: [ToggleFileAdapter] }, appData: {} }); + }; beforeAll(() => { customElements.define('diff-file', DiffFile); }); beforeEach(() => { - document.body.innerHTML = html; - assignAdapter(ToggleFileAdapter); - get('file').mount(); + mount(); }); it('starts with the file body visible', () => { diff --git a/spec/lib/gitlab/background_migration/delete_twitter_identities_spec.rb b/spec/lib/gitlab/background_migration/delete_twitter_identities_spec.rb new file mode 100644 index 00000000000..a8da2c3da15 --- /dev/null +++ b/spec/lib/gitlab/background_migration/delete_twitter_identities_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::DeleteTwitterIdentities, feature_category: :system_access do + let(:users_table) { table(:users) } + let(:identities_table) { table(:identities) } + + let!(:user) { users_table.create!(name: 'user-a', email: 'user-a@example.com', projects_limit: 10) } + let!(:twitter_identity) { identities_table.create!(user_id: user.id, provider: 'twitter') } + let!(:nontwitter_identity) { identities_table.create!(user_id: user.id, provider: 'definitely-not-twitter') } + + let(:migration) do + start_id, end_id = identities_table.pick('MIN(id), MAX(id)') + + described_class.new( + start_id: start_id, + end_id: end_id, + batch_table: :identities, + batch_column: :id, + sub_batch_size: 100, + pause_ms: 0, + job_arguments: [], + connection: ApplicationRecord.connection + ) + end + + subject(:migrate) { migration.perform } + + it 'deletes twitter identities' do + expect { migrate }.to change { identities_table.where(provider: 'twitter').count }.from(1).to(0) + end + + it 'keeps non-twitter identities' do + expect { migrate }.not_to change { identities_table.where.not(provider: 'twitter').count }.from(1) + end +end diff --git a/spec/migrations/20250415164706_queue_delete_twitter_identities_spec.rb b/spec/migrations/20250415164706_queue_delete_twitter_identities_spec.rb new file mode 100644 index 00000000000..10724e0218b --- /dev/null +++ b/spec/migrations/20250415164706_queue_delete_twitter_identities_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe QueueDeleteTwitterIdentities, migration: :gitlab_main_clusterwide, feature_category: :system_access do + let!(:batched_migration) { described_class::MIGRATION } + + it 'schedules a new batched migration' do + reversible_migration do |migration| + migration.before -> { + expect(batched_migration).not_to have_scheduled_batched_migration + } + + migration.after -> { + expect(batched_migration).to have_scheduled_batched_migration( + gitlab_schema: :gitlab_main_clusterwide, + table_name: :identities, + column_name: :id, + batch_size: Gitlab::Database::Migrations::BatchedBackgroundMigrationHelpers::BATCH_SIZE + ) + } + end + end +end diff --git a/spec/services/projects/destroy_service_spec.rb b/spec/services/projects/destroy_service_spec.rb index a30f83cd487..8714c57e567 100644 --- a/spec/services/projects/destroy_service_spec.rb +++ b/spec/services/projects/destroy_service_spec.rb @@ -923,6 +923,61 @@ RSpec.describe Projects::DestroyService, :aggregate_failures, :event_store_publi end end + describe '#delete_environments' do + let(:service) { described_class.new(project, user, {}) } + let(:batch_size) { described_class::BATCH_SIZE } + + context 'when there are no environments' do + it 'does not delete anything and logs zero count' do + expect(Gitlab::AppLogger).to receive(:info).with( + class: described_class.name, + project_id: project.id, + message: 'Deleting environments completed', + deleted_environment_count: 0 + ) + + expect { service.send(:delete_environments) }.not_to change(Environment, :count) + end + end + + context 'when there are fewer environments than the batch size' do + before do + create_list(:environment, 2, project: project) + end + + it 'deletes all environments in a single batch and logs the count' do + expect(Gitlab::AppLogger).to receive(:info).with( + class: described_class.name, + project_id: project.id, + message: 'Deleting environments completed', + deleted_environment_count: 2 + ) + + expect { service.send(:delete_environments) } + .to change { Environment.for_project(project).count }.from(2).to(0) + end + end + + context 'when there are more environments than the batch size' do + before do + stub_const("#{described_class}::BATCH_SIZE", 2) + create_list(:environment, 3, project: project) + end + + it 'deletes environments in multiple batches and logs the total count' do + expect(Gitlab::AppLogger).to receive(:info).with( + class: described_class.name, + project_id: project.id, + message: 'Deleting environments completed', + deleted_environment_count: 3 + ) + + expect { service.send(:delete_environments) } + .to change { Environment.for_project(project).count }.from(3).to(0) + end + end + end + def destroy_project(project, user, params = {}) described_class.new(project, user, params).public_send(async ? :async_execute : :execute) end diff --git a/spec/support/rspec_order_todo.yml b/spec/support/rspec_order_todo.yml index 273934357f8..7583be79bdc 100644 --- a/spec/support/rspec_order_todo.yml +++ b/spec/support/rspec_order_todo.yml @@ -836,7 +836,6 @@ - './ee/spec/lib/ee/gitlab/group_search_results_spec.rb' - './ee/spec/lib/ee/gitlab/hook_data/group_member_builder_spec.rb' - './ee/spec/lib/ee/gitlab/hook_data/issue_builder_spec.rb' -- './ee/spec/lib/ee/gitlab/import_export/after_export_strategies/custom_template_export_import_strategy_spec.rb' - './ee/spec/lib/ee/gitlab/import_export/group/tree_restorer_spec.rb' - './ee/spec/lib/ee/gitlab/import_export/group/tree_saver_spec.rb' - './ee/spec/lib/ee/gitlab/import_export/project/tree_restorer_spec.rb'