diff options
99 files changed, 4548 insertions, 561 deletions
@@ -268,7 +268,7 @@ gem 'rainbow', '~> 3.0' # rubocop:todo Gemfile/MissingFeatureCategory gem 'ruby-progressbar', '~> 1.10' # rubocop:todo Gemfile/MissingFeatureCategory # Linear-time regex library for untrusted regular expressions -gem 're2', '2.4.3' # rubocop:todo Gemfile/MissingFeatureCategory +gem 're2', '2.5.0' # rubocop:todo Gemfile/MissingFeatureCategory # Misc diff --git a/Gemfile.checksum b/Gemfile.checksum index a4a2ac559e7..1403fe315ed 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -498,16 +498,16 @@ {"name":"rbtrace","version":"0.4.14","platform":"ruby","checksum":"162bbf89cecabfc4f09c869b655f6f3a679c4870ebb7cbdcadf7393a81cc1769"}, {"name":"rbtree","version":"0.4.6","platform":"ruby","checksum":"14eea4469b24fd2472542e5f3eb105d6344c8ccf36f0b56d55fdcfeb4e0f10fc"}, {"name":"rchardet","version":"1.8.0","platform":"ruby","checksum":"693acd5253d5ade81a51940697955f6dd4bb2f0d245bda76a8e23deec70a52c7"}, -{"name":"re2","version":"2.4.3","platform":"aarch64-linux","checksum":"c61f88ed98553b67b5dcf89fcce4394845b51697983b50b59c8cbff438b2f43d"}, -{"name":"re2","version":"2.4.3","platform":"arm-linux","checksum":"12a9f7044b8667bd0b95115576d811fa22d370203b31452212f69b397b10c1c1"}, -{"name":"re2","version":"2.4.3","platform":"arm64-darwin","checksum":"3ea5da85de0b7d8dd9a871bc939fd340c47e52925a2f7786602bb0e9b558d3b1"}, -{"name":"re2","version":"2.4.3","platform":"ruby","checksum":"4b38ed4b8b3b75dd4fb02d5966b44a3a0d9b9c97d6ef98d264da39dc1a0d702d"}, -{"name":"re2","version":"2.4.3","platform":"x64-mingw-ucrt","checksum":"12a4b345e013693d5bb974d6a975f3a192d0961394986a98063a317bfaf193ad"}, -{"name":"re2","version":"2.4.3","platform":"x64-mingw32","checksum":"e7fef971923684598f3cdf2efa694c54681ef07c2f332b2109f7f9249d672f0c"}, -{"name":"re2","version":"2.4.3","platform":"x86-linux","checksum":"7783d5f18781bf9f73b27d616d10660f2604b9c0f869fe86b702393eb070d49c"}, -{"name":"re2","version":"2.4.3","platform":"x86-mingw32","checksum":"15a3c849cea067c5d71915231541e00753c69234ac403fc497bb55ee3878591a"}, -{"name":"re2","version":"2.4.3","platform":"x86_64-darwin","checksum":"4e695cfe5bf6297dfd78ed02ddb4e5cff39ff045aa59d1dc59e203530b073576"}, -{"name":"re2","version":"2.4.3","platform":"x86_64-linux","checksum":"8ec7616e492881b7703cb07ed2890d7b77b0af553415bf79b19ce6ef5e5c5fed"}, +{"name":"re2","version":"2.5.0","platform":"aarch64-linux","checksum":"b370a5f08b011f86bd41e4934c21f66389a34ac9949b754b321986c560a2ae8d"}, +{"name":"re2","version":"2.5.0","platform":"arm-linux","checksum":"91f73d83d638413f515a24e5a98e79c7bd40f1f059b13c857e751185f8b98b43"}, +{"name":"re2","version":"2.5.0","platform":"arm64-darwin","checksum":"4b20c4539a12787102b22012e678968af23f87e35f88843744835bd13ac9f6bc"}, +{"name":"re2","version":"2.5.0","platform":"ruby","checksum":"42bbf4292ef80aef54070d0904d3a7ccbb5f907dcf9006914a8b2ce6ea8b0646"}, +{"name":"re2","version":"2.5.0","platform":"x64-mingw-ucrt","checksum":"2d2a3c98e0dedebd1763f660773a776cb7d0a15d43428d68a7ff0f160d0c0d8f"}, +{"name":"re2","version":"2.5.0","platform":"x64-mingw32","checksum":"3ccd08b73995d461646ca1515535c8715756cc674df75394d072e056c87c380f"}, +{"name":"re2","version":"2.5.0","platform":"x86-linux","checksum":"ec165b08ee161c339582fb73ccd412f44b627ced1151cc8400c566b5bc2e8fbd"}, +{"name":"re2","version":"2.5.0","platform":"x86-mingw32","checksum":"39de555fb04b3ce0d37ea961525cab7a77e7015f0fc0692b30e4950d4d6314f8"}, +{"name":"re2","version":"2.5.0","platform":"x86_64-darwin","checksum":"1c926ee5f7ed7649f7d84d677f499a1c04542a4bf007a5765f9f33906753e2d3"}, +{"name":"re2","version":"2.5.0","platform":"x86_64-linux","checksum":"fbe9c0e939dceb4117fd9da5d35f69de57d96ef077edfb14e2661c877f4290ef"}, {"name":"recaptcha","version":"5.12.3","platform":"ruby","checksum":"37d1894add9e70a54d0c6c7f0ecbeedffbfa7d075acfbd4c509818dfdebdb7ee"}, {"name":"recursive-open-struct","version":"1.1.3","platform":"ruby","checksum":"a3538a72552fcebcd0ada657bdff313641a4a5fbc482c08cfb9a65acb1c9de5a"}, {"name":"redcarpet","version":"3.6.0","platform":"ruby","checksum":"8ad1889c0355ff4c47174af14edd06d62f45a326da1da6e8a121d59bdcd2e9e9"}, diff --git a/Gemfile.lock b/Gemfile.lock index 9c8bfff022f..e66433637df 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1338,7 +1338,7 @@ GEM optimist (>= 3.0.0) rbtree (0.4.6) rchardet (1.8.0) - re2 (2.4.3) + re2 (2.5.0) mini_portile2 (~> 2.8.5) recaptcha (5.12.3) json @@ -2023,7 +2023,7 @@ DEPENDENCIES rails-i18n (~> 7.0) rainbow (~> 3.0) rbtrace (~> 0.4) - re2 (= 2.4.3) + re2 (= 2.5.0) recaptcha (~> 5.12) redis (~> 4.8.0) redis-actionpack (~> 5.3.0) diff --git a/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note.vue b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note.vue index 9fea483c036..4423eb9e7b2 100644 --- a/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note.vue +++ b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note.vue @@ -54,6 +54,9 @@ export default { authorId() { return getIdFromGraphQLId(this.author.id); }, + showEditButton() { + return this.note.userPermissions.resolveNote; + }, editedAtClasses() { return this.showReplyButton ? 'gl-text-secondary gl-pl-3' : 'gl-text-secondary gl-pl-8'; }, @@ -106,7 +109,7 @@ export default { <div class="gl-display-inline-flex"> <abuse-report-note-actions :show-reply-button="showReplyButton" - show-edit-button + :show-edit-button="showEditButton" @startReplying="$emit('startReplying')" @startEditing="startEditing" /> diff --git a/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_note_permissions.fragment.graphql b/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_note_permissions.fragment.graphql index 01436436b93..31ca24e675f 100644 --- a/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_note_permissions.fragment.graphql +++ b/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_note_permissions.fragment.graphql @@ -1,3 +1,3 @@ fragment AbuseReportNotePermissions on NotePermissions { - adminNote + resolveNote } diff --git a/app/assets/javascripts/deploy_keys/graphql/client.js b/app/assets/javascripts/deploy_keys/graphql/client.js new file mode 100644 index 00000000000..3c183963683 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/client.js @@ -0,0 +1,47 @@ +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import typeDefs from './typedefs.graphql'; +import { resolvers } from './resolvers'; + +export const createApolloProvider = (endpoints) => { + const defaultClient = createDefaultClient(resolvers(endpoints), { + typeDefs, + cacheConfig: { + typePolicies: { + Query: { + fields: { + currentScope: { + read(data) { + return data || 'enabledKeys'; + }, + }, + currentPage: { + read(data) { + return data || 1; + }, + }, + pageInfo: { + read(data) { + return data || {}; + }, + }, + deployKeyToRemove: { + read(data) { + return data || null; + }, + }, + }, + }, + LocalDeployKey: { + deployKeysProjects: { + merge(_, incoming) { + return incoming; + }, + }, + }, + }, + }, + }); + + return new VueApollo({ defaultClient }); +}; diff --git a/app/assets/javascripts/deploy_keys/graphql/mutations/confirm_action.mutation.graphql b/app/assets/javascripts/deploy_keys/graphql/mutations/confirm_action.mutation.graphql new file mode 100644 index 00000000000..adc78e6d2d2 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/mutations/confirm_action.mutation.graphql @@ -0,0 +1,3 @@ +mutation confirmDisable($id: ID) { + confirmDisable(id: $id) @client +} diff --git a/app/assets/javascripts/deploy_keys/graphql/mutations/disable_key.mutation.graphql b/app/assets/javascripts/deploy_keys/graphql/mutations/disable_key.mutation.graphql new file mode 100644 index 00000000000..923dd636785 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/mutations/disable_key.mutation.graphql @@ -0,0 +1,3 @@ +mutation disableKey($id: ID!) { + disableKey(id: $id) @client +} diff --git a/app/assets/javascripts/deploy_keys/graphql/mutations/enable_key.mutation.graphql b/app/assets/javascripts/deploy_keys/graphql/mutations/enable_key.mutation.graphql new file mode 100644 index 00000000000..fb978679b7c --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/mutations/enable_key.mutation.graphql @@ -0,0 +1,3 @@ +mutation enableKey($id: ID!) { + enableKey(id: $id) @client +} diff --git a/app/assets/javascripts/deploy_keys/graphql/mutations/update_current_page.mutation.graphql b/app/assets/javascripts/deploy_keys/graphql/mutations/update_current_page.mutation.graphql new file mode 100644 index 00000000000..8e6438cdad0 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/mutations/update_current_page.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateCurrentDeployKeyPage($page: String) { + currentPage(page: $page) @client +} diff --git a/app/assets/javascripts/deploy_keys/graphql/mutations/update_current_scope.mutation.graphql b/app/assets/javascripts/deploy_keys/graphql/mutations/update_current_scope.mutation.graphql new file mode 100644 index 00000000000..3502eee5142 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/mutations/update_current_scope.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateCurrentScope($scope: DeployKeysScope) { + currentScope(scope: $scope) @client +} diff --git a/app/assets/javascripts/deploy_keys/graphql/queries/confirm_remove_key.query.graphql b/app/assets/javascripts/deploy_keys/graphql/queries/confirm_remove_key.query.graphql new file mode 100644 index 00000000000..11d6a6ab83c --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/queries/confirm_remove_key.query.graphql @@ -0,0 +1,5 @@ +query confirmRemoveKey { + deployKeyToRemove @client { + id + } +} diff --git a/app/assets/javascripts/deploy_keys/graphql/queries/current_page.query.graphql b/app/assets/javascripts/deploy_keys/graphql/queries/current_page.query.graphql new file mode 100644 index 00000000000..dc02d97531a --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/queries/current_page.query.graphql @@ -0,0 +1,3 @@ +query getCurrentDeployKeyPage { + currentPage @client +} diff --git a/app/assets/javascripts/deploy_keys/graphql/queries/current_scope.query.graphql b/app/assets/javascripts/deploy_keys/graphql/queries/current_scope.query.graphql new file mode 100644 index 00000000000..181f5c52254 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/queries/current_scope.query.graphql @@ -0,0 +1,3 @@ +query getCurrentScope { + currentScope @client +} diff --git a/app/assets/javascripts/deploy_keys/graphql/queries/deploy_keys.query.graphql b/app/assets/javascripts/deploy_keys/graphql/queries/deploy_keys.query.graphql new file mode 100644 index 00000000000..c98da2920cc --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/queries/deploy_keys.query.graphql @@ -0,0 +1,26 @@ +query getDeployKeys($projectPath: ID!, $scope: DeployKeysScope, $page: Integer) { + project(fullPath: $projectPath) { + id + deployKeys(scope: $scope, page: $page) @client { + id + title + fingerprintSha256 + fingerprint + editPath + destroyedWhenOrphaned + almostOrphaned + expiresAt + createdAt + enablePath + disablePath + deployKeysProjects { + canPush + project { + id + fullPath + fullName + } + } + } + } +} diff --git a/app/assets/javascripts/deploy_keys/graphql/resolvers.js b/app/assets/javascripts/deploy_keys/graphql/resolvers.js new file mode 100644 index 00000000000..1993801636e --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/resolvers.js @@ -0,0 +1,106 @@ +import { gql } from '@apollo/client/core'; +import axios from '~/lib/utils/axios_utils'; +import { + convertObjectPropsToCamelCase, + parseIntPagination, + normalizeHeaders, +} from '~/lib/utils/common_utils'; +import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql'; +import currentPageQuery from './queries/current_page.query.graphql'; +import currentScopeQuery from './queries/current_scope.query.graphql'; +import confirmRemoveKeyQuery from './queries/confirm_remove_key.query.graphql'; + +export const mapDeployKey = (deployKey) => ({ + ...convertObjectPropsToCamelCase(deployKey, { deep: true }), + __typename: 'LocalDeployKey', +}); + +export const resolvers = (endpoints) => ({ + Project: { + deployKeys(_, { scope, page }, { client }) { + const key = `${scope}Endpoint`; + let { [key]: endpoint } = endpoints; + + if (!endpoint) { + endpoint = endpoints.enabledKeysEndpoint; + } + + return axios.get(endpoint, { params: { page } }).then(({ headers, data }) => { + const normalizedHeaders = normalizeHeaders(headers); + const pageInfo = { + ...parseIntPagination(normalizedHeaders), + __typename: 'LocalPageInfo', + }; + client.writeQuery({ + query: pageInfoQuery, + variables: { input: { page, scope } }, + data: { pageInfo }, + }); + return data?.keys?.map(mapDeployKey) || []; + }); + }, + }, + Mutation: { + currentPage(_, { page }, { client }) { + client.writeQuery({ + query: currentPageQuery, + data: { currentPage: page }, + }); + }, + currentScope(_, { scope }, { client }) { + client.writeQuery({ + query: currentPageQuery, + data: { currentPage: 1 }, + }); + client.writeQuery({ + query: currentScopeQuery, + data: { currentScope: scope }, + }); + }, + disableKey(_, _variables, { client }) { + const { + deployKeyToRemove: { id }, + } = client.readQuery({ + query: confirmRemoveKeyQuery, + }); + + const fragment = gql` + fragment DisablePath on LocalDeployKey { + disablePath + } + `; + + const { disablePath } = client.readFragment({ fragment, id: `LocalDeployKey:${id}` }); + + return axios.put(disablePath).then(({ data }) => { + client.cache.evict({ fieldName: 'deployKeyToRemove' }); + client.cache.evict({ id: `LocalDeployKey:${id}` }); + client.cache.gc(); + + return data; + }); + }, + enableKey(_, { id }, { client }) { + const fragment = gql` + fragment EnablePath on LocalDeployKey { + enablePath + } + `; + + const { enablePath } = client.readFragment({ fragment, id: `LocalDeployKey:${id}` }); + + return axios.put(enablePath).then(({ data }) => { + client.cache.evict({ id: `LocalDeployKey:${id}` }); + client.cache.gc(); + + return data; + }); + }, + confirmDisable(_, { id }, { client }) { + client.writeQuery({ + query: confirmRemoveKeyQuery, + data: { deployKeyToRemove: id ? { id, __type: 'LocalDeployKey' } : null }, + }); + }, + }, +}); diff --git a/app/assets/javascripts/deploy_keys/graphql/typedefs.graphql b/app/assets/javascripts/deploy_keys/graphql/typedefs.graphql new file mode 100644 index 00000000000..a08dda3da92 --- /dev/null +++ b/app/assets/javascripts/deploy_keys/graphql/typedefs.graphql @@ -0,0 +1,45 @@ +#import "~/graphql_shared/client/page_info.typedefs.graphql" + +enum DeployKeysScope { + enabledKeys + availableProjectKeys + availablePublicKeys +} + +enum LocalDeployKeyActions { + enable + disable +} + +type LocalProject { + id: ID! + fullPath: String + fullName: String +} + +type LocalDeployKeysProject { + canPush: Boolean + projects: [LocalProject] +} + +type LocalDeployKey { + id: ID! + title: String + fingerprintSha256: String + fingerprint: String + editPath: String + isEnabled: Boolean + destroyedWhenOrphaned: Boolean + almostOrphaned: Boolean + expiresAt: String + createdAt: String + deployKeysProjects: [LocalDeployKeysProject] +} + +extend type LocalPageInfoInput { + scope: DeployKeysScope +} + +extend type Project { + deployKeys: [LocalDeployKey] +} diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 4e8b75536a4..8de0e0266c5 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -4,9 +4,9 @@ import { debounce } from 'lodash'; import { s__, __, sprintf } from '~/locale'; import { updateHistory, setUrlParams, queryToObject } from '~/lib/utils/url_utility'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql'; import environmentAppQuery from '../graphql/queries/environment_app.query.graphql'; import pollIntervalQuery from '../graphql/queries/poll_interval.query.graphql'; -import pageInfoQuery from '../graphql/queries/page_info.query.graphql'; import environmentToDeleteQuery from '../graphql/queries/environment_to_delete.query.graphql'; import environmentToRollbackQuery from '../graphql/queries/environment_to_rollback.query.graphql'; import environmentToStopQuery from '../graphql/queries/environment_to_stop.query.graphql'; @@ -56,6 +56,9 @@ export default { }, pageInfo: { query: pageInfoQuery, + variables() { + return { page: this.page }; + }, }, environmentToDelete: { query: environmentToDeleteQuery, diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js index 8f57069d89d..0eb12427914 100644 --- a/app/assets/javascripts/environments/graphql/client.js +++ b/app/assets/javascripts/environments/graphql/client.js @@ -1,7 +1,7 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql'; import environmentApp from './queries/environment_app.query.graphql'; -import pageInfoQuery from './queries/page_info.query.graphql'; import environmentToDeleteQuery from './queries/environment_to_delete.query.graphql'; import environmentToRollbackQuery from './queries/environment_to_rollback.query.graphql'; import environmentToStopQuery from './queries/environment_to_stop.query.graphql'; diff --git a/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql b/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql deleted file mode 100644 index d77ca05d46f..00000000000 --- a/app/assets/javascripts/environments/graphql/queries/page_info.query.graphql +++ /dev/null @@ -1,8 +0,0 @@ -query getPageInfo { - pageInfo @client { - total - perPage - nextPage - previousPage - } -} diff --git a/app/assets/javascripts/environments/graphql/resolvers/base.js b/app/assets/javascripts/environments/graphql/resolvers/base.js index c9f9a2da13a..7d2a0689da2 100644 --- a/app/assets/javascripts/environments/graphql/resolvers/base.js +++ b/app/assets/javascripts/environments/graphql/resolvers/base.js @@ -6,13 +6,13 @@ import { normalizeHeaders, } from '~/lib/utils/common_utils'; +import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql'; import pollIntervalQuery from '../queries/poll_interval.query.graphql'; import environmentToRollbackQuery from '../queries/environment_to_rollback.query.graphql'; import environmentToStopQuery from '../queries/environment_to_stop.query.graphql'; import environmentToDeleteQuery from '../queries/environment_to_delete.query.graphql'; import environmentToChangeCanaryQuery from '../queries/environment_to_change_canary.query.graphql'; import isEnvironmentStoppingQuery from '../queries/is_environment_stopping.query.graphql'; -import pageInfoQuery from '../queries/page_info.query.graphql'; const buildErrors = (errors = []) => ({ errors, diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql index 24898c2cadb..a235e387930 100644 --- a/app/assets/javascripts/environments/graphql/typedefs.graphql +++ b/app/assets/javascripts/environments/graphql/typedefs.graphql @@ -1,3 +1,5 @@ +#import "~/graphql_shared/client/page_info.typedefs.graphql" + type LocalEnvironment { id: Int! globalId: ID! @@ -55,13 +57,6 @@ type LocalErrors { errors: [String!]! } -type LocalPageInfo { - total: Int! - perPage: Int! - nextPage: Int! - previousPage: Int! -} - type k8sPodStatus { phase: String } diff --git a/app/assets/javascripts/graphql_shared/client/page_info.query.graphql b/app/assets/javascripts/graphql_shared/client/page_info.query.graphql new file mode 100644 index 00000000000..958d3eade68 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/client/page_info.query.graphql @@ -0,0 +1,8 @@ +query getPageInfo($input: LocalPageInfoInput) { + pageInfo(input: $input) @client { + total + perPage + nextPage + previousPage + } +} diff --git a/app/assets/javascripts/graphql_shared/client/page_info.typedefs.graphql b/app/assets/javascripts/graphql_shared/client/page_info.typedefs.graphql new file mode 100644 index 00000000000..2c74fa4cc34 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/client/page_info.typedefs.graphql @@ -0,0 +1,10 @@ +type LocalPageInfoInput { + page: Int +} + +type LocalPageInfo { + total: Int! + perPage: Int! + nextPage: Int! + previousPage: Int! +} diff --git a/app/assets/javascripts/security_configuration/components/app.vue b/app/assets/javascripts/security_configuration/components/app.vue index 32d46a0d4af..4a4c91c6ba7 100644 --- a/app/assets/javascripts/security_configuration/components/app.vue +++ b/app/assets/javascripts/security_configuration/components/app.vue @@ -1,43 +1,21 @@ <script> import { GlTab, GlTabs, GlSprintf, GlLink, GlAlert } from '@gitlab/ui'; import Api from '~/api'; -import { __, s__ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import SectionLayout from '~/vue_shared/security_configuration/components/section_layout.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { SERVICE_PING_SECURITY_CONFIGURATION_THREAT_MANAGEMENT_VISIT } from '~/tracking/constants'; -import AutoDevOpsAlert from './auto_dev_ops_alert.vue'; -import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue'; import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY, TAB_VULNERABILITY_MANAGEMENT_INDEX, -} from './constants'; + i18n, +} from '../constants'; +import AutoDevOpsAlert from './auto_dev_ops_alert.vue'; +import AutoDevOpsEnabledAlert from './auto_dev_ops_enabled_alert.vue'; import FeatureCard from './feature_card.vue'; import TrainingProviderList from './training_provider_list.vue'; -export const i18n = { - configurationHistory: s__('SecurityConfiguration|Configuration history'), - securityTesting: s__('SecurityConfiguration|Security testing'), - latestPipelineDescription: s__( - `SecurityConfiguration|The status of the tools only applies to the - default branch and is based on the %{linkStart}latest pipeline%{linkEnd}.`, - ), - description: s__( - `SecurityConfiguration|Once you've enabled a scan for the default branch, - any subsequent feature branch you create will include the scan. An enabled - scanner will not be reflected as such until the pipeline has been - successfully executed and it has generated valid artifacts.`, - ), - securityConfiguration: __('Security configuration'), - vulnerabilityManagement: s__('SecurityConfiguration|Vulnerability Management'), - securityTraining: s__('SecurityConfiguration|Security training'), - securityTrainingDescription: s__( - 'SecurityConfiguration|Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability.', - ), - securityTrainingDoc: s__('SecurityConfiguration|Learn more about vulnerability training'), -}; - export default { i18n, components: { diff --git a/app/assets/javascripts/security_configuration/components/constants.js b/app/assets/javascripts/security_configuration/components/constants.js deleted file mode 100644 index fd713a7a504..00000000000 --- a/app/assets/javascripts/security_configuration/components/constants.js +++ /dev/null @@ -1,330 +0,0 @@ -import { helpPagePath } from '~/helpers/help_page_helper'; -import { __, s__ } from '~/locale'; - -import { - REPORT_TYPE_SAST, - REPORT_TYPE_SAST_IAC, - REPORT_TYPE_DAST, - REPORT_TYPE_DAST_PROFILES, - REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, - REPORT_TYPE_SECRET_DETECTION, - REPORT_TYPE_DEPENDENCY_SCANNING, - REPORT_TYPE_CONTAINER_SCANNING, - REPORT_TYPE_COVERAGE_FUZZING, - REPORT_TYPE_CORPUS_MANAGEMENT, - REPORT_TYPE_API_FUZZING, -} from '~/vue_shared/security_reports/constants'; - -import kontraLogo from 'images/vulnerability/kontra-logo.svg?raw'; -import scwLogo from 'images/vulnerability/scw-logo.svg?raw'; -import secureflagLogo from 'images/vulnerability/secureflag-logo.svg?raw'; -import configureSastMutation from '../graphql/configure_sast.mutation.graphql'; -import configureSastIacMutation from '../graphql/configure_iac.mutation.graphql'; -import configureSecretDetectionMutation from '../graphql/configure_secret_detection.mutation.graphql'; - -/** - * Translations & helpPagePaths for Security Configuration Page - * Make sure to add new scanner translations to the SCANNER_NAMES_MAP below. - */ - -export const SAST_NAME = __('Static Application Security Testing (SAST)'); -export const SAST_SHORT_NAME = s__('ciReport|SAST'); -export const SAST_DESCRIPTION = __('Analyze your source code for known vulnerabilities.'); -export const SAST_HELP_PATH = helpPagePath('user/application_security/sast/index'); -export const SAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/sast/index', { - anchor: 'configuration', -}); - -export const SAST_IAC_NAME = __('Infrastructure as Code (IaC) Scanning'); -export const SAST_IAC_SHORT_NAME = s__('ciReport|SAST IaC'); -export const SAST_IAC_DESCRIPTION = __( - 'Analyze your infrastructure as code configuration files for known vulnerabilities.', -); -export const SAST_IAC_HELP_PATH = helpPagePath('user/application_security/iac_scanning/index'); -export const SAST_IAC_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/iac_scanning/index', - { - anchor: 'configuration', - }, -); - -export const DAST_NAME = __('Dynamic Application Security Testing (DAST)'); -export const DAST_SHORT_NAME = s__('ciReport|DAST'); -export const DAST_DESCRIPTION = s__( - 'ciReport|Analyze a deployed version of your web application for known vulnerabilities by examining it from the outside in. DAST works by simulating external attacks on your application while it is running.', -); -export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index'); -export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', { - anchor: 'enable-automatic-dast-run', -}); -export const DAST_BADGE_TEXT = __('Available on demand'); -export const DAST_BADGE_TOOLTIP = __( - 'On-demand scans run outside of the DevOps cycle and find vulnerabilities in your projects', -); - -export const DAST_PROFILES_NAME = __('DAST profiles'); -export const DAST_PROFILES_DESCRIPTION = s__( - 'SecurityConfiguration|Manage profiles for use by DAST scans.', -); -export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage profiles'); - -export const BAS_BADGE_TEXT = s__('SecurityConfiguration|Incubating feature'); -export const BAS_BADGE_TOOLTIP = s__( - 'SecurityConfiguration|Breach and Attack Simulation is an incubating feature extending existing security testing by simulating adversary activity.', -); -export const BAS_DESCRIPTION = s__( - 'SecurityConfiguration|Simulate breach and attack scenarios against your running application by attempting to detect and exploit known vulnerabilities.', -); -export const BAS_HELP_PATH = helpPagePath( - 'user/application_security/breach_and_attack_simulation/index', -); -export const BAS_NAME = s__('SecurityConfiguration|Breach and Attack Simulation (BAS)'); -export const BAS_SHORT_NAME = s__('SecurityConfiguration|BAS'); - -export const BAS_DAST_FEATURE_FLAG_DESCRIPTION = s__( - 'SecurityConfiguration|Enable incubating Breach and Attack Simulation focused features such as callback attacks in your DAST scans.', -); -export const BAS_DAST_FEATURE_FLAG_HELP_PATH = helpPagePath( - 'user/application_security/breach_and_attack_simulation/index', - { anchor: 'extend-dynamic-application-security-testing-dast' }, -); -export const BAS_DAST_FEATURE_FLAG_NAME = s__( - 'SecurityConfiguration|Out-of-Band Application Security Testing (OAST)', -); - -export const SECRET_DETECTION_NAME = __('Secret Detection'); -export const SECRET_DETECTION_DESCRIPTION = __( - 'Analyze your source code and git history for secrets.', -); -export const SECRET_DETECTION_HELP_PATH = helpPagePath( - 'user/application_security/secret_detection/index', -); -export const SECRET_DETECTION_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/secret_detection/index', - { anchor: 'configuration' }, -); - -export const DEPENDENCY_SCANNING_NAME = __('Dependency Scanning'); -export const DEPENDENCY_SCANNING_DESCRIPTION = __( - 'Analyze your dependencies for known vulnerabilities.', -); -export const DEPENDENCY_SCANNING_HELP_PATH = helpPagePath( - 'user/application_security/dependency_scanning/index', -); -export const DEPENDENCY_SCANNING_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/dependency_scanning/index', - { anchor: 'configuration' }, -); - -export const CONTAINER_SCANNING_NAME = __('Container Scanning'); -export const CONTAINER_SCANNING_DESCRIPTION = __( - 'Check your Docker images for known vulnerabilities.', -); -export const CONTAINER_SCANNING_HELP_PATH = helpPagePath( - 'user/application_security/container_scanning/index', -); -export const CONTAINER_SCANNING_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/container_scanning/index', - { anchor: 'configuration' }, -); - -export const COVERAGE_FUZZING_NAME = __('Coverage Fuzzing'); -export const COVERAGE_FUZZING_DESCRIPTION = __( - 'Find bugs in your code with coverage-guided fuzzing.', -); -export const COVERAGE_FUZZING_HELP_PATH = helpPagePath( - 'user/application_security/coverage_fuzzing/index', -); -export const COVERAGE_FUZZING_CONFIG_HELP_PATH = helpPagePath( - 'user/application_security/coverage_fuzzing/index', - { anchor: 'enable-coverage-guided-fuzz-testing' }, -); - -export const CORPUS_MANAGEMENT_NAME = __('Corpus Management'); -export const CORPUS_MANAGEMENT_DESCRIPTION = s__( - 'SecurityConfiguration|Manage corpus files used as seed inputs with coverage-guided fuzzing.', -); -export const CORPUS_MANAGEMENT_CONFIG_TEXT = s__('SecurityConfiguration|Manage corpus'); - -export const API_FUZZING_NAME = __('API Fuzzing'); -export const API_FUZZING_DESCRIPTION = __('Find bugs in your code with API fuzzing.'); -export const API_FUZZING_HELP_PATH = helpPagePath('user/application_security/api_fuzzing/index'); - -export const CLUSTER_IMAGE_SCANNING_NAME = s__('ciReport|Cluster Image Scanning'); - -export const SCANNER_NAMES_MAP = { - SAST: SAST_SHORT_NAME, - SAST_IAC: SAST_IAC_NAME, - DAST: DAST_SHORT_NAME, - API_FUZZING: API_FUZZING_NAME, - CONTAINER_SCANNING: CONTAINER_SCANNING_NAME, - COVERAGE_FUZZING: COVERAGE_FUZZING_NAME, - SECRET_DETECTION: SECRET_DETECTION_NAME, - DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME, - BREACH_AND_ATTACK_SIMULATION: BAS_NAME, - CLUSTER_IMAGE_SCANNING: CLUSTER_IMAGE_SCANNING_NAME, - GENERIC: s__('ciReport|Manually added'), -}; - -export const securityFeatures = [ - { - name: SAST_NAME, - shortName: SAST_SHORT_NAME, - description: SAST_DESCRIPTION, - helpPath: SAST_HELP_PATH, - configurationHelpPath: SAST_CONFIG_HELP_PATH, - type: REPORT_TYPE_SAST, - }, - { - name: SAST_IAC_NAME, - shortName: SAST_IAC_SHORT_NAME, - description: SAST_IAC_DESCRIPTION, - helpPath: SAST_IAC_HELP_PATH, - configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH, - type: REPORT_TYPE_SAST_IAC, - }, - { - badge: { - text: DAST_BADGE_TEXT, - tooltipText: DAST_BADGE_TOOLTIP, - variant: 'info', - }, - secondary: { - type: REPORT_TYPE_DAST_PROFILES, - name: DAST_PROFILES_NAME, - description: DAST_PROFILES_DESCRIPTION, - configurationText: DAST_PROFILES_CONFIG_TEXT, - }, - name: DAST_NAME, - shortName: DAST_SHORT_NAME, - description: DAST_DESCRIPTION, - helpPath: DAST_HELP_PATH, - configurationHelpPath: DAST_CONFIG_HELP_PATH, - type: REPORT_TYPE_DAST, - anchor: 'dast', - }, - { - name: DEPENDENCY_SCANNING_NAME, - description: DEPENDENCY_SCANNING_DESCRIPTION, - helpPath: DEPENDENCY_SCANNING_HELP_PATH, - configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH, - type: REPORT_TYPE_DEPENDENCY_SCANNING, - anchor: 'dependency-scanning', - }, - { - name: CONTAINER_SCANNING_NAME, - description: CONTAINER_SCANNING_DESCRIPTION, - helpPath: CONTAINER_SCANNING_HELP_PATH, - configurationHelpPath: CONTAINER_SCANNING_CONFIG_HELP_PATH, - type: REPORT_TYPE_CONTAINER_SCANNING, - }, - { - name: SECRET_DETECTION_NAME, - description: SECRET_DETECTION_DESCRIPTION, - helpPath: SECRET_DETECTION_HELP_PATH, - configurationHelpPath: SECRET_DETECTION_CONFIG_HELP_PATH, - type: REPORT_TYPE_SECRET_DETECTION, - }, - { - name: API_FUZZING_NAME, - description: API_FUZZING_DESCRIPTION, - helpPath: API_FUZZING_HELP_PATH, - type: REPORT_TYPE_API_FUZZING, - }, - { - name: COVERAGE_FUZZING_NAME, - description: COVERAGE_FUZZING_DESCRIPTION, - helpPath: COVERAGE_FUZZING_HELP_PATH, - configurationHelpPath: COVERAGE_FUZZING_CONFIG_HELP_PATH, - type: REPORT_TYPE_COVERAGE_FUZZING, - secondary: { - type: REPORT_TYPE_CORPUS_MANAGEMENT, - name: CORPUS_MANAGEMENT_NAME, - description: CORPUS_MANAGEMENT_DESCRIPTION, - configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT, - }, - }, - { - anchor: 'bas', - badge: { - alwaysDisplay: true, - text: BAS_BADGE_TEXT, - tooltipText: BAS_BADGE_TOOLTIP, - variant: 'info', - }, - description: BAS_DESCRIPTION, - name: BAS_NAME, - helpPath: BAS_HELP_PATH, - secondary: { - configurationHelpPath: BAS_DAST_FEATURE_FLAG_HELP_PATH, - description: BAS_DAST_FEATURE_FLAG_DESCRIPTION, - name: BAS_DAST_FEATURE_FLAG_NAME, - }, - shortName: BAS_SHORT_NAME, - type: REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, - }, -]; - -export const featureToMutationMap = { - [REPORT_TYPE_SAST]: { - mutationId: 'configureSast', - getMutationPayload: (projectPath) => ({ - mutation: configureSastMutation, - variables: { - input: { - projectPath, - configuration: { global: [], pipeline: [], analyzers: [] }, - }, - }, - }), - }, - [REPORT_TYPE_SAST_IAC]: { - mutationId: 'configureSastIac', - getMutationPayload: (projectPath) => ({ - mutation: configureSastIacMutation, - variables: { - input: { - projectPath, - }, - }, - }), - }, - [REPORT_TYPE_SECRET_DETECTION]: { - mutationId: 'configureSecretDetection', - getMutationPayload: (projectPath) => ({ - mutation: configureSecretDetectionMutation, - variables: { - input: { - projectPath, - }, - }, - }), - }, -}; - -export const AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY = - 'security_configuration_auto_devops_enabled_dismissed_projects'; - -// Fetch the svg path from the GraphQL query once this issue is resolved -// https://gitlab.com/gitlab-org/gitlab/-/issues/346899 -export const TEMP_PROVIDER_LOGOS = { - Kontra: { - svg: kontraLogo, - }, - [__('Secure Code Warrior')]: { - svg: scwLogo, - }, - SecureFlag: { - svg: secureflagLogo, - }, -}; - -// Use the `url` field from the GraphQL query once this issue is resolved -// https://gitlab.com/gitlab-org/gitlab/-/issues/356129 -export const TEMP_PROVIDER_URLS = { - Kontra: 'https://application.security/', - [__('Secure Code Warrior')]: 'https://www.securecodewarrior.com/', - SecureFlag: 'https://www.secureflag.com/', -}; - -export const TAB_VULNERABILITY_MANAGEMENT_INDEX = 1; diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue index d424ec6dfeb..ae2894e25a2 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -18,6 +18,8 @@ import { TRACK_TOGGLE_TRAINING_PROVIDER_LABEL, TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION, TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL, + TEMP_PROVIDER_LOGOS, + TEMP_PROVIDER_URLS, } from '~/security_configuration/constants'; import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql'; import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql'; @@ -26,7 +28,6 @@ import { updateSecurityTrainingCache, updateSecurityTrainingOptimisticResponse, } from '~/security_configuration/graphql/cache_utils'; -import { TEMP_PROVIDER_LOGOS, TEMP_PROVIDER_URLS } from './constants'; const i18n = { providerQueryErrorMessage: __( diff --git a/app/assets/javascripts/security_configuration/constants.js b/app/assets/javascripts/security_configuration/constants.js index 14eb10ac2aa..94bcf81a3eb 100644 --- a/app/assets/javascripts/security_configuration/constants.js +++ b/app/assets/javascripts/security_configuration/constants.js @@ -1,3 +1,334 @@ +import kontraLogo from 'images/vulnerability/kontra-logo.svg?raw'; +import scwLogo from 'images/vulnerability/scw-logo.svg?raw'; +import secureflagLogo from 'images/vulnerability/secureflag-logo.svg?raw'; +import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +import { + REPORT_TYPE_SAST, + REPORT_TYPE_SAST_IAC, + REPORT_TYPE_DAST, + REPORT_TYPE_DAST_PROFILES, + REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, + REPORT_TYPE_SECRET_DETECTION, + REPORT_TYPE_DEPENDENCY_SCANNING, + REPORT_TYPE_CONTAINER_SCANNING, + REPORT_TYPE_COVERAGE_FUZZING, + REPORT_TYPE_CORPUS_MANAGEMENT, + REPORT_TYPE_API_FUZZING, +} from '~/vue_shared/security_reports/constants'; + +import configureSastMutation from './graphql/configure_sast.mutation.graphql'; +import configureSastIacMutation from './graphql/configure_iac.mutation.graphql'; +import configureSecretDetectionMutation from './graphql/configure_secret_detection.mutation.graphql'; + +/** + * Translations & helpPagePaths for Security Configuration Page + * Make sure to add new scanner translations to the SCANNER_NAMES_MAP below. + */ + +export const SAST_NAME = __('Static Application Security Testing (SAST)'); +export const SAST_SHORT_NAME = s__('ciReport|SAST'); +export const SAST_DESCRIPTION = __('Analyze your source code for known vulnerabilities.'); +export const SAST_HELP_PATH = helpPagePath('user/application_security/sast/index'); +export const SAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/sast/index', { + anchor: 'configuration', +}); + +export const SAST_IAC_NAME = __('Infrastructure as Code (IaC) Scanning'); +export const SAST_IAC_SHORT_NAME = s__('ciReport|SAST IaC'); +export const SAST_IAC_DESCRIPTION = __( + 'Analyze your infrastructure as code configuration files for known vulnerabilities.', +); +export const SAST_IAC_HELP_PATH = helpPagePath('user/application_security/iac_scanning/index'); +export const SAST_IAC_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/iac_scanning/index', + { + anchor: 'configuration', + }, +); + +export const DAST_NAME = __('Dynamic Application Security Testing (DAST)'); +export const DAST_SHORT_NAME = s__('ciReport|DAST'); +export const DAST_DESCRIPTION = s__( + 'ciReport|Analyze a deployed version of your web application for known vulnerabilities by examining it from the outside in. DAST works by simulating external attacks on your application while it is running.', +); +export const DAST_HELP_PATH = helpPagePath('user/application_security/dast/index'); +export const DAST_CONFIG_HELP_PATH = helpPagePath('user/application_security/dast/index', { + anchor: 'enable-automatic-dast-run', +}); +export const DAST_BADGE_TEXT = __('Available on demand'); +export const DAST_BADGE_TOOLTIP = __( + 'On-demand scans run outside of the DevOps cycle and find vulnerabilities in your projects', +); + +export const DAST_PROFILES_NAME = __('DAST profiles'); +export const DAST_PROFILES_DESCRIPTION = s__( + 'SecurityConfiguration|Manage profiles for use by DAST scans.', +); +export const DAST_PROFILES_CONFIG_TEXT = s__('SecurityConfiguration|Manage profiles'); + +export const BAS_BADGE_TEXT = s__('SecurityConfiguration|Incubating feature'); +export const BAS_BADGE_TOOLTIP = s__( + 'SecurityConfiguration|Breach and Attack Simulation is an incubating feature extending existing security testing by simulating adversary activity.', +); +export const BAS_DESCRIPTION = s__( + 'SecurityConfiguration|Simulate breach and attack scenarios against your running application by attempting to detect and exploit known vulnerabilities.', +); +export const BAS_HELP_PATH = helpPagePath( + 'user/application_security/breach_and_attack_simulation/index', +); +export const BAS_NAME = s__('SecurityConfiguration|Breach and Attack Simulation (BAS)'); +export const BAS_SHORT_NAME = s__('SecurityConfiguration|BAS'); + +export const BAS_DAST_FEATURE_FLAG_DESCRIPTION = s__( + 'SecurityConfiguration|Enable incubating Breach and Attack Simulation focused features such as callback attacks in your DAST scans.', +); +export const BAS_DAST_FEATURE_FLAG_HELP_PATH = helpPagePath( + 'user/application_security/breach_and_attack_simulation/index', + { anchor: 'extend-dynamic-application-security-testing-dast' }, +); +export const BAS_DAST_FEATURE_FLAG_NAME = s__( + 'SecurityConfiguration|Out-of-Band Application Security Testing (OAST)', +); + +export const SECRET_DETECTION_NAME = __('Secret Detection'); +export const SECRET_DETECTION_DESCRIPTION = __( + 'Analyze your source code and git history for secrets.', +); +export const SECRET_DETECTION_HELP_PATH = helpPagePath( + 'user/application_security/secret_detection/index', +); +export const SECRET_DETECTION_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/secret_detection/index', + { anchor: 'configuration' }, +); + +export const DEPENDENCY_SCANNING_NAME = __('Dependency Scanning'); +export const DEPENDENCY_SCANNING_DESCRIPTION = __( + 'Analyze your dependencies for known vulnerabilities.', +); +export const DEPENDENCY_SCANNING_HELP_PATH = helpPagePath( + 'user/application_security/dependency_scanning/index', +); +export const DEPENDENCY_SCANNING_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/dependency_scanning/index', + { anchor: 'configuration' }, +); + +export const CONTAINER_SCANNING_NAME = __('Container Scanning'); +export const CONTAINER_SCANNING_DESCRIPTION = __( + 'Check your Docker images for known vulnerabilities.', +); +export const CONTAINER_SCANNING_HELP_PATH = helpPagePath( + 'user/application_security/container_scanning/index', +); +export const CONTAINER_SCANNING_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/container_scanning/index', + { anchor: 'configuration' }, +); + +export const COVERAGE_FUZZING_NAME = __('Coverage Fuzzing'); +export const COVERAGE_FUZZING_DESCRIPTION = __( + 'Find bugs in your code with coverage-guided fuzzing.', +); +export const COVERAGE_FUZZING_HELP_PATH = helpPagePath( + 'user/application_security/coverage_fuzzing/index', +); +export const COVERAGE_FUZZING_CONFIG_HELP_PATH = helpPagePath( + 'user/application_security/coverage_fuzzing/index', + { anchor: 'enable-coverage-guided-fuzz-testing' }, +); + +export const CORPUS_MANAGEMENT_NAME = __('Corpus Management'); +export const CORPUS_MANAGEMENT_DESCRIPTION = s__( + 'SecurityConfiguration|Manage corpus files used as seed inputs with coverage-guided fuzzing.', +); +export const CORPUS_MANAGEMENT_CONFIG_TEXT = s__('SecurityConfiguration|Manage corpus'); + +export const API_FUZZING_NAME = __('API Fuzzing'); +export const API_FUZZING_DESCRIPTION = __('Find bugs in your code with API fuzzing.'); +export const API_FUZZING_HELP_PATH = helpPagePath('user/application_security/api_fuzzing/index'); + +export const CLUSTER_IMAGE_SCANNING_NAME = s__('ciReport|Cluster Image Scanning'); + +export const SCANNER_NAMES_MAP = { + SAST: SAST_SHORT_NAME, + SAST_IAC: SAST_IAC_NAME, + DAST: DAST_SHORT_NAME, + API_FUZZING: API_FUZZING_NAME, + CONTAINER_SCANNING: CONTAINER_SCANNING_NAME, + COVERAGE_FUZZING: COVERAGE_FUZZING_NAME, + SECRET_DETECTION: SECRET_DETECTION_NAME, + DEPENDENCY_SCANNING: DEPENDENCY_SCANNING_NAME, + BREACH_AND_ATTACK_SIMULATION: BAS_NAME, + CLUSTER_IMAGE_SCANNING: CLUSTER_IMAGE_SCANNING_NAME, + GENERIC: s__('ciReport|Manually added'), +}; + +export const securityFeatures = [ + { + name: SAST_NAME, + shortName: SAST_SHORT_NAME, + description: SAST_DESCRIPTION, + helpPath: SAST_HELP_PATH, + configurationHelpPath: SAST_CONFIG_HELP_PATH, + type: REPORT_TYPE_SAST, + }, + { + name: SAST_IAC_NAME, + shortName: SAST_IAC_SHORT_NAME, + description: SAST_IAC_DESCRIPTION, + helpPath: SAST_IAC_HELP_PATH, + configurationHelpPath: SAST_IAC_CONFIG_HELP_PATH, + type: REPORT_TYPE_SAST_IAC, + }, + { + badge: { + text: DAST_BADGE_TEXT, + tooltipText: DAST_BADGE_TOOLTIP, + variant: 'info', + }, + secondary: { + type: REPORT_TYPE_DAST_PROFILES, + name: DAST_PROFILES_NAME, + description: DAST_PROFILES_DESCRIPTION, + configurationText: DAST_PROFILES_CONFIG_TEXT, + }, + name: DAST_NAME, + shortName: DAST_SHORT_NAME, + description: DAST_DESCRIPTION, + helpPath: DAST_HELP_PATH, + configurationHelpPath: DAST_CONFIG_HELP_PATH, + type: REPORT_TYPE_DAST, + anchor: 'dast', + }, + { + name: DEPENDENCY_SCANNING_NAME, + description: DEPENDENCY_SCANNING_DESCRIPTION, + helpPath: DEPENDENCY_SCANNING_HELP_PATH, + configurationHelpPath: DEPENDENCY_SCANNING_CONFIG_HELP_PATH, + type: REPORT_TYPE_DEPENDENCY_SCANNING, + anchor: 'dependency-scanning', + }, + { + name: CONTAINER_SCANNING_NAME, + description: CONTAINER_SCANNING_DESCRIPTION, + helpPath: CONTAINER_SCANNING_HELP_PATH, + configurationHelpPath: CONTAINER_SCANNING_CONFIG_HELP_PATH, + type: REPORT_TYPE_CONTAINER_SCANNING, + }, + { + name: SECRET_DETECTION_NAME, + description: SECRET_DETECTION_DESCRIPTION, + helpPath: SECRET_DETECTION_HELP_PATH, + configurationHelpPath: SECRET_DETECTION_CONFIG_HELP_PATH, + type: REPORT_TYPE_SECRET_DETECTION, + }, + { + name: API_FUZZING_NAME, + description: API_FUZZING_DESCRIPTION, + helpPath: API_FUZZING_HELP_PATH, + type: REPORT_TYPE_API_FUZZING, + }, + { + name: COVERAGE_FUZZING_NAME, + description: COVERAGE_FUZZING_DESCRIPTION, + helpPath: COVERAGE_FUZZING_HELP_PATH, + configurationHelpPath: COVERAGE_FUZZING_CONFIG_HELP_PATH, + type: REPORT_TYPE_COVERAGE_FUZZING, + secondary: { + type: REPORT_TYPE_CORPUS_MANAGEMENT, + name: CORPUS_MANAGEMENT_NAME, + description: CORPUS_MANAGEMENT_DESCRIPTION, + configurationText: CORPUS_MANAGEMENT_CONFIG_TEXT, + }, + }, + { + anchor: 'bas', + badge: { + alwaysDisplay: true, + text: BAS_BADGE_TEXT, + tooltipText: BAS_BADGE_TOOLTIP, + variant: 'info', + }, + description: BAS_DESCRIPTION, + name: BAS_NAME, + helpPath: BAS_HELP_PATH, + secondary: { + configurationHelpPath: BAS_DAST_FEATURE_FLAG_HELP_PATH, + description: BAS_DAST_FEATURE_FLAG_DESCRIPTION, + name: BAS_DAST_FEATURE_FLAG_NAME, + }, + shortName: BAS_SHORT_NAME, + type: REPORT_TYPE_BREACH_AND_ATTACK_SIMULATION, + }, +]; + +export const featureToMutationMap = { + [REPORT_TYPE_SAST]: { + mutationId: 'configureSast', + getMutationPayload: (projectPath) => ({ + mutation: configureSastMutation, + variables: { + input: { + projectPath, + configuration: { global: [], pipeline: [], analyzers: [] }, + }, + }, + }), + }, + [REPORT_TYPE_SAST_IAC]: { + mutationId: 'configureSastIac', + getMutationPayload: (projectPath) => ({ + mutation: configureSastIacMutation, + variables: { + input: { + projectPath, + }, + }, + }), + }, + [REPORT_TYPE_SECRET_DETECTION]: { + mutationId: 'configureSecretDetection', + getMutationPayload: (projectPath) => ({ + mutation: configureSecretDetectionMutation, + variables: { + input: { + projectPath, + }, + }, + }), + }, +}; + +export const AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY = + 'security_configuration_auto_devops_enabled_dismissed_projects'; + +// Fetch the svg path from the GraphQL query once this issue is resolved +// https://gitlab.com/gitlab-org/gitlab/-/issues/346899 +export const TEMP_PROVIDER_LOGOS = { + Kontra: { + svg: kontraLogo, + }, + [__('Secure Code Warrior')]: { + svg: scwLogo, + }, + SecureFlag: { + svg: secureflagLogo, + }, +}; + +// Use the `url` field from the GraphQL query once this issue is resolved +// https://gitlab.com/gitlab-org/gitlab/-/issues/356129 +export const TEMP_PROVIDER_URLS = { + Kontra: 'https://application.security/', + [__('Secure Code Warrior')]: 'https://www.securecodewarrior.com/', + SecureFlag: 'https://www.secureflag.com/', +}; + +export const TAB_VULNERABILITY_MANAGEMENT_INDEX = 1; + export const TRACK_TOGGLE_TRAINING_PROVIDER_ACTION = 'toggle_security_training_provider'; export const TRACK_TOGGLE_TRAINING_PROVIDER_LABEL = 'update_security_training_provider'; export const TRACK_CLICK_TRAINING_LINK_ACTION = 'click_security_training_link'; @@ -6,3 +337,25 @@ export const TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL = 'security_training_provider export const TRACK_TRAINING_LOADED_ACTION = 'security_training_link_loaded'; export const TRACK_PROMOTION_BANNER_CTA_CLICK_ACTION = 'click_button'; export const TRACK_PROMOTION_BANNER_CTA_CLICK_LABEL = 'security_training_promotion_cta'; + +export const i18n = { + configurationHistory: s__('SecurityConfiguration|Configuration history'), + securityTesting: s__('SecurityConfiguration|Security testing'), + latestPipelineDescription: s__( + `SecurityConfiguration|The status of the tools only applies to the + default branch and is based on the %{linkStart}latest pipeline%{linkEnd}.`, + ), + description: s__( + `SecurityConfiguration|Once you've enabled a scan for the default branch, + any subsequent feature branch you create will include the scan. An enabled + scanner will not be reflected as such until the pipeline has been + successfully executed and it has generated valid artifacts.`, + ), + securityConfiguration: __('Security configuration'), + vulnerabilityManagement: s__('SecurityConfiguration|Vulnerability Management'), + securityTraining: s__('SecurityConfiguration|Security training'), + securityTrainingDescription: s__( + 'SecurityConfiguration|Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability. Please note that security training is not accessible in an environment that is offline.', + ), + securityTrainingDoc: s__('SecurityConfiguration|Learn more about vulnerability training'), +}; diff --git a/app/assets/javascripts/security_configuration/index.js b/app/assets/javascripts/security_configuration/index.js index aa3c9c87622..8086b200891 100644 --- a/app/assets/javascripts/security_configuration/index.js +++ b/app/assets/javascripts/security_configuration/index.js @@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { parseBooleanDataAttributes } from '~/lib/utils/dom_utils'; import SecurityConfigurationApp from './components/app.vue'; -import { securityFeatures } from './components/constants'; +import { securityFeatures } from './constants'; import { augmentFeatures } from './utils'; export const initSecurityConfiguration = (el) => { diff --git a/app/assets/javascripts/security_configuration/utils.js b/app/assets/javascripts/security_configuration/utils.js index 7f0caf1af46..59b49cb3820 100644 --- a/app/assets/javascripts/security_configuration/utils.js +++ b/app/assets/javascripts/security_configuration/utils.js @@ -1,5 +1,5 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants'; +import { SCANNER_NAMES_MAP } from '~/security_configuration/constants'; import { REPORT_TYPE_DAST } from '~/vue_shared/security_reports/constants'; /** diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 473e8a936fd..9ce5448d86e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -122,6 +122,7 @@ export default class MergeRequestStore { this.availableAutoMergeStrategies, ); this.ffOnlyEnabled = data.ff_only_enabled; + this.ffMergePossible = data.ff_merge_possible; this.isRemovingSourceBranch = this.isRemovingSourceBranch || false; this.mergeRequestState = data.state; this.isOpen = this.mergeRequestState === STATUS_OPEN; diff --git a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue index b81d288d932..dccff4a288f 100644 --- a/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue +++ b/app/assets/javascripts/vue_shared/security_configuration/components/manage_via_mr.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants'; +import { featureToMutationMap } from 'ee_else_ce/security_configuration/constants'; import { parseErrorMessage } from '~/lib/utils/error_message'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { sprintf, s__ } from '~/locale'; diff --git a/app/graphql/types/abuse_report_type.rb b/app/graphql/types/abuse_report_type.rb index 2532530cfa9..dc40800af94 100644 --- a/app/graphql/types/abuse_report_type.rb +++ b/app/graphql/types/abuse_report_type.rb @@ -10,8 +10,6 @@ module Types authorize :read_abuse_report - expose_permissions Types::PermissionTypes::AbuseReport - field :id, Types::GlobalIDType[::AbuseReport], null: false, description: 'Global ID of the abuse report.' diff --git a/app/graphql/types/permission_types/abuse_report.rb b/app/graphql/types/permission_types/abuse_report.rb deleted file mode 100644 index abd5d545d02..00000000000 --- a/app/graphql/types/permission_types/abuse_report.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Types - module PermissionTypes - class AbuseReport < BasePermissionType - graphql_name 'AbuseReportPermissions' - - abilities :read_abuse_report, :create_note - end - end -end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index f4526a4f182..25a2cc8a5ae 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -208,14 +208,10 @@ module GroupsHelper end def access_level_roles_user_can_assign(group) - return {} unless current_user - return group.access_level_roles if current_user.can_admin_all_resources? - - max_access_level = group.highest_group_member(current_user)&.access_level - - return {} unless max_access_level - - group.access_level_roles.select { |_k, v| v <= max_access_level } + max_access_level = group.max_member_access_for_user(current_user) + group.access_level_roles.select do |_name, access_level| + access_level <= max_access_level + end end def groups_projects_more_actions_dropdown_data(source) diff --git a/app/policies/abuse_report_policy.rb b/app/policies/abuse_report_policy.rb index 043dbd0cb89..ca200c538f2 100644 --- a/app/policies/abuse_report_policy.rb +++ b/app/policies/abuse_report_policy.rb @@ -3,6 +3,7 @@ class AbuseReportPolicy < ::BasePolicy rule { admin }.policy do enable :read_abuse_report + enable :read_note enable :create_note end end diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb index cef3f4555df..3374cd46729 100644 --- a/app/serializers/merge_request_poll_widget_entity.rb +++ b/app/serializers/merge_request_poll_widget_entity.rb @@ -15,6 +15,8 @@ class MergeRequestPollWidgetEntity < Grape::Entity merge_request.project.merge_requests_ff_only_enabled end + expose :ff_merge_possible?, as: :ff_merge_possible + # User entities expose :merge_user, using: UserEntity diff --git a/config/feature_flags/development/saml_microsoft_attribute_names.yml b/config/feature_flags/development/saml_microsoft_attribute_names.yml deleted file mode 100644 index 808f6f1e3ab..00000000000 --- a/config/feature_flags/development/saml_microsoft_attribute_names.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: saml_microsoft_attribute_names -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136734 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/430348 -milestone: '16.7' -type: development -group: group::authentication -default_enabled: false diff --git a/config/feature_flags/ops/run_clickhouse_migrations_automatically.yml b/config/feature_flags/ops/run_clickhouse_migrations_automatically.yml index a031dc60475..f53e528fa21 100644 --- a/config/feature_flags/ops/run_clickhouse_migrations_automatically.yml +++ b/config/feature_flags/ops/run_clickhouse_migrations_automatically.yml @@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/434848 milestone: '16.7' type: ops group: group::runner -default_enabled: false +default_enabled: true diff --git a/doc/.vale/gitlab/spelling-exceptions.txt b/doc/.vale/gitlab/spelling-exceptions.txt index f1d95dbd5f0..cc02821b6d6 100644 --- a/doc/.vale/gitlab/spelling-exceptions.txt +++ b/doc/.vale/gitlab/spelling-exceptions.txt @@ -177,6 +177,7 @@ Codecov codenames Codepen CodeSandbox +Codey Cognito Coinbase colocate diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 9ea2a7a0281..74a9a2f1c76 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -5353,7 +5353,7 @@ Input type: `MemberRoleCreateInput` | <a id="mutationmemberrolecreatebaseaccesslevel"></a>`baseAccessLevel` | [`MemberAccessLevel!`](#memberaccesslevel) | Base access level for the custom role. | | <a id="mutationmemberrolecreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationmemberrolecreatedescription"></a>`description` | [`String`](#string) | Description of the member role. | -| <a id="mutationmemberrolecreategrouppath"></a>`groupPath` | [`ID!`](#id) | Group the member role to mutate is in. | +| <a id="mutationmemberrolecreategrouppath"></a>`groupPath` | [`ID`](#id) | Group the member role to mutate is in. Required for SaaS. | | <a id="mutationmemberrolecreatemanageprojectaccesstokens"></a>`manageProjectAccessTokens` | [`Boolean`](#boolean) | Permission to admin project access tokens. | | <a id="mutationmemberrolecreatename"></a>`name` | [`String`](#string) | Name of the member role. | | <a id="mutationmemberrolecreatepermissions"></a>`permissions` | [`[MemberRolePermission!]`](#memberrolepermission) | List of all customizable permissions. | @@ -13857,7 +13857,6 @@ An abuse report. | <a id="abusereportdiscussions"></a>`discussions` | [`DiscussionConnection!`](#discussionconnection) | All discussions on this noteable. (see [Connections](#connections)) | | <a id="abusereportid"></a>`id` | [`AbuseReportID!`](#abusereportid) | Global ID of the abuse report. | | <a id="abusereportlabels"></a>`labels` | [`LabelConnection`](#labelconnection) | Labels of the abuse report. (see [Connections](#connections)) | -| <a id="abusereportuserpermissions"></a>`userPermissions` | [`AbuseReportPermissions!`](#abusereportpermissions) | Permissions for the current user on the resource. | #### Fields with arguments @@ -13877,15 +13876,6 @@ four standard [pagination arguments](#connection-pagination-arguments): | ---- | ---- | ----------- | | <a id="abusereportnotesfilter"></a>`filter` | [`NotesFilterType`](#notesfiltertype) | Type of notes collection: ALL_NOTES, ONLY_COMMENTS, ONLY_ACTIVITY. | -### `AbuseReportPermissions` - -#### Fields - -| Name | Type | Description | -| ---- | ---- | ----------- | -| <a id="abusereportpermissionscreatenote"></a>`createNote` | [`Boolean!`](#boolean) | If `true`, the user can perform `create_note` on this resource. | -| <a id="abusereportpermissionsreadabusereport"></a>`readAbuseReport` | [`Boolean!`](#boolean) | If `true`, the user can perform `read_abuse_report` on this resource. | - ### `AccessLevel` Represents the access level of a relationship between a User and object that it is related to. diff --git a/doc/development/internal_analytics/internal_event_instrumentation/migration.md b/doc/development/internal_analytics/internal_event_instrumentation/migration.md index 32a68011e5a..2ef439e21e9 100644 --- a/doc/development/internal_analytics/internal_event_instrumentation/migration.md +++ b/doc/development/internal_analytics/internal_event_instrumentation/migration.md @@ -42,17 +42,14 @@ Gitlab::InternalEvents.track_event('ci_templates_unique', namespace: namespace, In addition, you have to create definitions for the metrics that you would like to track. -To generate metric definitions, you can use the generator like this: +To generate metric definitions, you can use the generator: ```shell -bin/rails g gitlab:analytics:internal_events \ - --time_frames=7d 28d\ - --group=project_management \ - --event=ci_templates_unique \ - --unique=user.id \ - --mr=https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121544 +ruby scripts/internal_events/cli.rb ``` +The generator walks you through the required inputs step-by-step. + ### Frontend If you are using the `Tracking` mixin in the Vue component, you can replace it with the `InternalEvents` mixin. diff --git a/doc/development/internal_analytics/internal_event_instrumentation/quick_start.md b/doc/development/internal_analytics/internal_event_instrumentation/quick_start.md index 34be2080e95..6f48f83e7ca 100644 --- a/doc/development/internal_analytics/internal_event_instrumentation/quick_start.md +++ b/doc/development/internal_analytics/internal_event_instrumentation/quick_start.md @@ -17,30 +17,13 @@ In order to instrument your code with Internal Events Tracking you need to do th ## Defining event and metrics -<div class="video-fallback"> - See the video about <a href="https://www.youtube.com/watch?v=QICKWznLyy0">adding events and metrics using the generator</a> -</div> -<figure class="video_container"> - <iframe src="https://www.youtube-nocookie.com/embed/QICKWznLyy0" frameborder="0" allowfullscreen="true"> </iframe> -</figure> - -To create an event and metric definitions you can use the `internal_events` generator. - -This example creates an event definition for an event called `project_created` and two metric definitions, which are aggregated every 7 and 28 days. +To create event and/or metric definitions, use the `internal_events` generator from the `gitlab` directory: ```shell -bundle exec rails generate gitlab:analytics:internal_events \ ---time_frames=7d 28d \ ---group=project_management \ ---event=project_created \ ---unique=user.id \ ---mr=https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121544 +ruby scripts/internal_events/cli.rb ``` -Where: - -- `time_frames`: Valid options are `7d` and `28d` if you provide a `unique` value and `7d`, `28d` and `all` for metrics without `unique`. -- `unique`: Valid options are `user.id`, `project.id`, and `namespace.id`, as they are logged as part of the standard context. We [are actively working](https://gitlab.com/gitlab-org/gitlab/-/issues/411255) on a way to define uniqueness on arbitrary properties sent with the event, such as `merge_request.id`. +This CLI will help you create the correct defintion files based on your specific use-case, then provide code examples for instrumentation and testing. ## Trigger events @@ -52,11 +35,11 @@ To trigger an event, call the `Gitlab::InternalEvents.track_event` method with t ```ruby Gitlab::InternalEvents.track_event( - "i_code_review_user_apply_suggestion", - user: user, - namespace: namespace, - project: project - ) + "i_code_review_user_apply_suggestion", + user: user, + namespace: namespace, + project: project +) ``` This method automatically increments all RedisHLL metrics relating to the event `i_code_review_user_apply_suggestion`, and sends a corresponding Snowplow event with all named arguments and standard context (SaaS only). diff --git a/doc/integration/saml.md b/doc/integration/saml.md index 6a312f76388..3423b1bde6d 100644 --- a/doc/integration/saml.md +++ b/doc/integration/saml.md @@ -717,16 +717,7 @@ your provider's support. ### Configure assertions -> - Microsoft Azure/Entra ID attribute support [introduced on self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/420766) in GitLab 16.7. -> - Microsoft Azure/Entra ID attribute support [introduced on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136734) in GitLab 16.7 [with a flag](../administration/feature_flags.md) named `saml_microsoft_attribute_names`. Disabled by default, and available to GitLab.com administrators only. - -FLAG: -On self-managed GitLab, Microsoft Azure/Entra ID attributes are supported by default. -In the following table, these attributes begin with either `http://schemas.xmlsoap.org` -or `http://schemas.microsoft.com`. -On GitLab.com, Microsoft Azure/Entra ID attributes are introduced -[with a flag](../administration/feature_flags.md) named `saml_microsoft_attribute_names`. -On GitLab.com, this feature is unavailable but can be configured by GitLab.com administrators only. +> Microsoft Azure/Entra ID attribute support [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/420766) in GitLab 16.7. NOTE: The attributes are case-sensitive. diff --git a/doc/operations/incident_management/incident_timeline_events.md b/doc/operations/incident_management/incident_timeline_events.md index c5b611bfc6e..baea158b234 100644 --- a/doc/operations/incident_management/incident_timeline_events.md +++ b/doc/operations/incident_management/incident_timeline_events.md @@ -85,7 +85,7 @@ of an incident. ### When labels change **(EXPERIMENT)** -> [Introduced]([issue-link](https://gitlab.com/gitlab-org/gitlab/-/issues/365489)) in GitLab 15.3 [with a flag](../../administration/feature_flags.md) named `incident_timeline_events_from_labels`. Disabled by default. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/365489) in GitLab 15.3 [with a flag](../../administration/feature_flags.md) named `incident_timeline_events_from_labels`. Disabled by default. FLAG: On self-managed GitLab, by default this feature is not available. To make it available per project or for your entire instance, an administrator can [enable the feature flag](../../administration/feature_flags.md) named `incident_timeline_events_from_labels`. diff --git a/doc/topics/gitlab_flow.md b/doc/topics/gitlab_flow.md new file mode 100644 index 00000000000..5eba213cbd4 --- /dev/null +++ b/doc/topics/gitlab_flow.md @@ -0,0 +1,11 @@ +--- +redirect_to: 'https://about.gitlab.com/blog/2023/07/27/gitlab-flow-duo/' +remove_date: '2024-07-27' +--- + +This document was moved to [another location](https://about.gitlab.com/blog/2023/07/27/gitlab-flow-duo/). + +<!-- This redirect file can be deleted after <2024-07-27>. --> +<!-- Redirects that point to other docs in the same project expire in three months. --> +<!-- Redirects that point to docs in a different project or site (for example, link is not relative and starts with `https:`) expire in one year. --> +<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/redirects.html --> diff --git a/doc/user/ai_features.md b/doc/user/ai_features.md index f22a1e0c36a..4518ec6df78 100644 --- a/doc/user/ai_features.md +++ b/doc/user/ai_features.md @@ -12,23 +12,23 @@ info: To determine the technical writer assigned to the Stage/Group associated w GitLab is creating AI-assisted features across our DevSecOps platform. These features aim to help increase velocity and solve key pain points across the software development lifecycle. -| Feature | Purpose | Large Language Model | Current availability | Maturity | -|-|-|-|-|-| -| [Suggested Reviewers](project/merge_requests/reviews/index.md#gitlab-duo-suggested-reviewers) | Assists in creating faster and higher-quality reviews by automatically suggesting reviewers for your merge request. | GitLab creates a machine learning model for each project, which is used to generate reviewers <br><br> [View the issue](https://gitlab.com/gitlab-org/modelops/applied-ml/applied-ml-updates/-/issues/10) | SaaS only <br><br> Ultimate tier | [Generally Available (GA)](../policy/experiment-beta-support.md#generally-available-ga) | -| [Code Suggestions](project/repository/code_suggestions/index.md) | Helps you write code more efficiently by viewing code suggestions as you type. | For Code Completion: Vertex AI Codey [`code-gecko`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/code-completion) <br><br> For Code Generation: Anthropic [`Claude-2`](https://docs.anthropic.com/claude/reference/selecting-a-model)| [SaaS: All tiers](project/repository/code_suggestions/saas.md) <br><br> [Self-managed: Premium and Ultimate with Cloud Licensing](project/repository/code_suggestions/self_managed.md) | [Beta](../policy/experiment-beta-support.md#beta) | -| [Vulnerability summary](application_security/vulnerabilities/index.md#explaining-a-vulnerability) | Helps you remediate vulnerabilities more efficiently, boost your skills, and write more secure code. | Vertex AI Codey [`text-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text) <br><br> Anthropic [`Claude-2`](https://docs.anthropic.com/claude/reference/selecting-a-model) if degraded performance | SaaS only <br><br> Ultimate tier | [Beta](../policy/experiment-beta-support.md#beta) | -| [Vulnerability resolution](application_security/vulnerabilities/index.md#explaining-a-vulnerability) | Generates a merge request containing the changes required to mitigate a vulnerability. | Vertex AI Codey [`code-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/code-generation) | SaaS only <br><br> Ultimate tier | [Experiment](../policy/experiment-beta-support.md#experiment) | -| [Code explanation](#explain-code-in-the-web-ui-with-code-explanation) | Helps you understand code by explaining it in English language. | Vertex AI Codey [`codechat-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/code-chat) | SaaS only <br><br> Ultimate tier | [Experiment](../policy/experiment-beta-support.md#experiment) | -| [GitLab Duo Chat](gitlab_duo_chat.md) | Process and generate text and code in a conversational manner. Helps you quickly identify useful information in large volumes of text in issues, epics, code, and GitLab documentation. | Anthropic [`Claude-2`](https://docs.anthropic.com/claude/reference/selecting-a-model) <br><br> Vertext AI Codey [`textembedding-gecko`](https://cloud.google.com/vertex-ai/docs/generative-ai/embeddings/get-text-embeddings) | SaaS only <br><br> Ultimate tier | [Beta](../policy/experiment-beta-support.md#beta) | -| [Value stream forecasting](#forecast-deployment-frequency-with-value-stream-forecasting) | Assists you with predicting productivity metrics and identifying anomalies across your software development lifecycle. | Statistical forecasting | SaaS only <br> Self-managed <br><br> Ultimate tier | [Experiment](../policy/experiment-beta-support.md#experiment) | -| [Discussion summary](#summarize-issue-discussions-with-discussion-summary) | Assists with quickly getting everyone up to speed on lengthy conversations to help ensure you are all on the same page. | Vertex AI Codey [`text-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text) | SaaS only <br><br> Ultimate tier | [Experiment](../policy/experiment-beta-support.md#experiment) | -| [Merge request summary](project/merge_requests/ai_in_merge_requests.md#summarize-merge-request-changes) | Efficiently communicate the impact of your merge request changes. | Vertex AI Codey [`text-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text) | SaaS only <br><br> Ultimate tier | [Experiment](../policy/experiment-beta-support.md#experiment) | -| [Code review summary](project/merge_requests/ai_in_merge_requests.md#summarize-my-merge-request-review) | Helps ease merge request handoff between authors and reviewers and help reviewers efficiently understand suggestions. | Vertex AI Codey [`text-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text) | SaaS only <br><br> Ultimate tier | [Experiment](../policy/experiment-beta-support.md#experiment) | -| [Merge request template population](project/merge_requests/ai_in_merge_requests.md#fill-in-merge-request-templates) | Generate a description for the merge request based on the contents of the template. | Vertex AI Codey [`text-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text) | SaaS only <br><br> Ultimate tier | [Experiment](../policy/experiment-beta-support.md#experiment) | -| [Test generation](project/merge_requests/ai_in_merge_requests.md#generate-suggested-tests-in-merge-requests) | Automates repetitive tasks and helps catch bugs early. | Vertex AI Codey [`text-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text) | SaaS only <br><br> Ultimate tier | [Experiment](../policy/experiment-beta-support.md#experiment) | -| [Git suggestions](https://gitlab.com/gitlab-org/gitlab/-/issues/409636) | Helps you discover or recall Git commands when and where you need them. | Vertex AI Codey [`codechat-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/code-chat) | SaaS only <br><br> Ultimate tier | [Experiment](../policy/experiment-beta-support.md#experiment) | -| [Root cause analysis](#root-cause-analysis) | Assists you in determining the root cause for a pipeline failure and failed CI/CD build. | Vertex AI Codey [`text-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text) | SaaS only <br><br> Ultimate tier | [Experiment](../policy/experiment-beta-support.md#experiment) | -| [Issue description generation](#summarize-an-issue-with-issue-description-generation) | Generate issue descriptions. | Anthropic [`Claude-2`](https://docs.anthropic.com/claude/reference/selecting-a-model) | SaaS only <br><br> Ultimate tier | [Experiment](../policy/experiment-beta-support.md#experiment) | +| Goal | Feature | Tier/Offering/Status | +|---|---|---| +| Helps you discover or recall Git commands when and where you need them. | [Git suggestions](https://gitlab.com/gitlab-org/gitlab/-/issues/409636) | **(ULTIMATE SAAS EXPERIMENT)** | +| Assists with quickly getting everyone up to speed on lengthy conversations to help ensure you are all on the same page. | [Discussion summary](#summarize-issue-discussions-with-discussion-summary) | **(ULTIMATE SAAS EXPERIMENT)** | +| Generates issue descriptions. | [Issue description generation](#summarize-an-issue-with-issue-description-generation) | **(ULTIMATE SAAS EXPERIMENT)** | +| Helps you write code more efficiently by viewing code suggestions as you type. <br><br><i class="fa fa-youtube-play youtube" aria-hidden="true"></i> [Watch overview](https://www.youtube.com/watch?v=hCAyCTacdAQ) | [Code Suggestions](project/repository/code_suggestions/index.md) | For SaaS: **(FREE BETA)**<br><br> For self-managed: **(ULTIMATE BETA)** | +| Automates repetitive tasks and helps catch bugs early. | [Test generation](project/merge_requests/ai_in_merge_requests.md#generate-suggested-tests-in-merge-requests) | **(ULTIMATE SAAS EXPERIMENT)** | +| Generates a description for the merge request based on the contents of the template. | [Merge request template population](project/merge_requests/ai_in_merge_requests.md#fill-in-merge-request-templates) | **(ULTIMATE SAAS EXPERIMENT)** | +| Assists in creating faster and higher-quality reviews by automatically suggesting reviewers for your merge request. <br><br><i class="fa fa-youtube-play youtube" aria-hidden="true"></i> [Watch overview](https://www.youtube.com/watch?v=ivwZQgh4Rxw) | [Suggested Reviewers](project/merge_requests/reviews/index.md#gitlab-duo-suggested-reviewers) | **(ULTIMATE SAAS)** | +| Efficiently communicates the impact of your merge request changes. | [Merge request summary](project/merge_requests/ai_in_merge_requests.md#summarize-merge-request-changes) | **(ULTIMATE SAAS EXPERIMENT)** | +| Helps ease merge request handoff between authors and reviewers and help reviewers efficiently understand suggestions. | [Code review summary](project/merge_requests/ai_in_merge_requests.md#summarize-my-merge-request-review) | **(ULTIMATE SAAS EXPERIMENT)** | +| Helps you remediate vulnerabilities more efficiently, boost your skills, and write more secure code. <br><br><i class="fa fa-youtube-play youtube" aria-hidden="true"></i> [Watch overview](https://www.youtube.com/watch?v=6sDf73QOav8) | [Vulnerability summary](application_security/vulnerabilities/index.md#explaining-a-vulnerability) | **(ULTIMATE SAAS BETA)** | +| Generates a merge request containing the changes required to mitigate a vulnerability. | [Vulnerability resolution](application_security/vulnerabilities/index.md#explaining-a-vulnerability) | **(ULTIMATE SAAS EXPERIMENT)** | +| Helps you understand code by explaining it in English language. <br><br><i class="fa fa-youtube-play youtube" aria-hidden="true"></i> [Watch overview](https://www.youtube.com/watch?v=1izKaLmmaCA) | [Code explanation](#explain-code-in-the-web-ui-with-code-explanation) | **(ULTIMATE SAAS EXPERIMENT)** | +| Processes and generates text and code in a conversational manner. Helps you quickly identify useful information in large volumes of text in issues, epics, code, and GitLab documentation. | [GitLab Duo Chat](gitlab_duo_chat.md) | **(ULTIMATE SAAS BETA)** | +| Assists you in determining the root cause for a pipeline failure and failed CI/CD build. | [Root cause analysis](#root-cause-analysis) | **(ULTIMATE SAAS EXPERIMENT)** | +| Assists you with predicting productivity metrics and identifying anomalies across your software development lifecycle. | [Value stream forecasting](#forecast-deployment-frequency-with-value-stream-forecasting) | **(ULTIMATE ALL EXPERIMENT)** | ## Enable AI/ML features @@ -186,6 +186,26 @@ language model referenced above. For details about this Beta feature, see [GitLab Duo Chat](gitlab_duo_chat.md). +## Language models + +| Feature | Large Language Model | +|---|---| +| [Git suggestions](https://gitlab.com/gitlab-org/gitlab/-/issues/409636) | Vertex AI Codey [`codechat-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/code-chat) | +| [Discussion summary](#summarize-issue-discussions-with-discussion-summary) | Vertex AI Codey [`text-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text) | +| [Issue description generation](#summarize-an-issue-with-issue-description-generation) | Anthropic [`Claude-2`](https://docs.anthropic.com/claude/reference/selecting-a-model) | +| [Code Suggestions](project/repository/code_suggestions/index.md) | For Code Completion: Vertex AI Codey [`code-gecko`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/code-completion) For Code Generation: Anthropic [`Claude-2`](https://docs.anthropic.com/claude/reference/selecting-a-model) | +| [Test generation](project/merge_requests/ai_in_merge_requests.md#generate-suggested-tests-in-merge-requests) | Vertex AI Codey [`text-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text) | +| [Merge request template population](project/merge_requests/ai_in_merge_requests.md#fill-in-merge-request-templates) | Vertex AI Codey [`text-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text) | +| [Suggested Reviewers](project/merge_requests/reviews/index.md#gitlab-duo-suggested-reviewers) | GitLab creates a machine learning model for each project, which is used to generate reviewers [View the issue](https://gitlab.com/gitlab-org/modelops/applied-ml/applied-ml-updates/-/issues/10) | +| [Merge request summary](project/merge_requests/ai_in_merge_requests.md#summarize-merge-request-changes) | Vertex AI Codey [`text-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text) | +| [Code review summary](project/merge_requests/ai_in_merge_requests.md#summarize-my-merge-request-review) | Vertex AI Codey [`text-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text) | +| [Vulnerability summary](application_security/vulnerabilities/index.md#explaining-a-vulnerability) | Vertex AI Codey [`text-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text) Anthropic [`Claude-2`](https://docs.anthropic.com/claude/reference/selecting-a-model) if degraded performance | +| [Vulnerability resolution](application_security/vulnerabilities/index.md#explaining-a-vulnerability) | Vertex AI Codey [`code-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/code-generation) | +| [Code explanation](#explain-code-in-the-web-ui-with-code-explanation) | Vertex AI Codey [`codechat-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/code-chat) | +| [GitLab Duo Chat](gitlab_duo_chat.md) | Anthropic [`Claude-2`](https://docs.anthropic.com/claude/reference/selecting-a-model) Vertex AI Codey [`textembedding-gecko`](https://cloud.google.com/vertex-ai/docs/generative-ai/embeddings/get-text-embeddings) | +| [Root cause analysis](#root-cause-analysis) | Vertex AI Codey [`text-bison`](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/text) | +| [Value stream forecasting](#forecast-deployment-frequency-with-value-stream-forecasting) | Statistical forecasting | + ## Data usage GitLab AI features leverage generative AI to help increase velocity and aim to help make you more productive. Each feature operates independently of other features and is not required for other features to function. GitLab selects the best-in-class large-language models for specific tasks. We use [Google Vertex AI Models](https://cloud.google.com/vertex-ai/docs/generative-ai/learn/overview#genai-models) and [Anthropic Claude](https://www.anthropic.com/product). diff --git a/doc/user/group/saml_sso/troubleshooting.md b/doc/user/group/saml_sso/troubleshooting.md index 8773020f6b9..a2576f37ac9 100644 --- a/doc/user/group/saml_sso/troubleshooting.md +++ b/doc/user/group/saml_sso/troubleshooting.md @@ -357,18 +357,21 @@ Additionally, see [troubleshooting users receiving a 404 after sign in](#users-r ## Message: The SAML response did not contain an email address. Either the SAML identity provider is not configured to send the attribute, or the identity provider directory does not have an email address value for your user -This error appears when the SAML response does not contain the user's email address in an **email** or **mail** attribute as shown in the following example: +This error appears when the SAML response does not contain the user's email address in an **email** or **mail** attribute. +Ensure the SAML identity provider is configured to send a [supported mail attribute](../../../integration/saml.md). + +Examples: ```xml <Attribute Name="email"> - <AttributeValue>user@domain.com‹/AttributeValue> + <AttributeValue>user@example.com‹/AttributeValue> </Attribute> ``` -Attribute names starting with phrases such as `http://schemas.xmlsoap.org/ws/2005/05/identity/claims` and `http://schemas.microsoft.com/ws/2008/06/identity/claims/` are supported. +Attribute names starting with phrases such as `http://schemas.xmlsoap.org/ws/2005/05/identity/claims` and `http://schemas.microsoft.com/ws/2008/06/identity/claims/` are supported by default beginning in GitLab 16.7. ```xml <Attribute Name="http://schemas.microsoft.com/ws/2008/06/identity/claims/emailaddress"> - <AttributeValue>user@domain.com‹/AttributeValue> + <AttributeValue>user@example.com‹/AttributeValue> </Attribute> ``` diff --git a/locale/gitlab.pot b/locale/gitlab.pot index cfe69105890..e394036ad2c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -11954,10 +11954,13 @@ msgstr "" msgid "CodeOwner|Pattern" msgstr "" +msgid "CodeSuggestionsGAAlert| (Code Suggestions transitions to a paid feature on %{date}.)" +msgstr "" + msgid "CodeSuggestionsGAAlert|Learn more" msgstr "" -msgid "CodeSuggestionsGAAlert|Quickly and securely author code by getting suggestions in your IDE while you type. Available in multiple languages, try Code Suggestions today." +msgid "CodeSuggestionsGAAlert|Quickly and securely author code by getting suggestions in %{link_start}your IDE%{link_end} while you type. Available in multiple languages. Try Code Suggestions today." msgstr "" msgid "CodeSuggestionsGAAlert|Try GitLab Duo Code Suggestions today" @@ -11969,9 +11972,6 @@ msgstr "" msgid "CodeSuggestionsSM|Code Suggestions" msgstr "" -msgid "CodeSuggestionsSM|Code Suggestions %{beta}" -msgstr "" - msgid "CodeSuggestionsSM|Enable Code Suggestions for this instance" msgstr "" @@ -18636,9 +18636,6 @@ msgstr "" msgid "Enable security training" msgstr "" -msgid "Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability." -msgstr "" - msgid "Enable shared runners for all projects and subgroups in this group." msgstr "" @@ -43244,7 +43241,7 @@ msgstr "" msgid "SecurityConfiguration|Enable incubating Breach and Attack Simulation focused features such as callback attacks in your DAST scans." msgstr "" -msgid "SecurityConfiguration|Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability." +msgid "SecurityConfiguration|Enable security training to help your developers learn how to fix vulnerabilities. Developers can view security training from selected educational providers, relevant to the detected vulnerability. Please note that security training is not accessible in an environment that is offline." msgstr "" msgid "SecurityConfiguration|Enabled" diff --git a/qa/qa/page/dashboard/projects.rb b/qa/qa/page/dashboard/projects.rb index ff822a6b117..32e3af117fc 100644 --- a/qa/qa/page/dashboard/projects.rb +++ b/qa/qa/page/dashboard/projects.rb @@ -25,9 +25,12 @@ module QA element 'new-project-button' end - def has_project_with_access_role?(project_name, access_role) - within_element('project-content', text: project_name) do - has_element?('user-role-content', text: access_role) + def has_filtered_project_with_access_role?(project_name, access_role) + # Retry as in some situations the filter may fail if sidekiq hasn't had a chance + # to process all jobs after the project create + QA::Support::Retrier.retry_until(max_duration: 60, retry_on_exception: true) do + filter_by_name(project_name) + has_project_with_access_role?(project_name, access_role) end end @@ -54,6 +57,14 @@ module QA def clear_project_filter fill_element('project-filter-form-container', "") end + + private + + def has_project_with_access_role?(project_name, access_role) + within_element('project-content', text: project_name) do + has_element?('user-role-content', text: access_role) + end + end end end end diff --git a/qa/qa/specs/features/browser_ui/9_data_stores/project/invite_group_to_project_spec.rb b/qa/qa/specs/features/browser_ui/9_data_stores/project/invite_group_to_project_spec.rb index 28253c1f1df..313b2d828eb 100644 --- a/qa/qa/specs/features/browser_ui/9_data_stores/project/invite_group_to_project_spec.rb +++ b/qa/qa/specs/features/browser_ui/9_data_stores/project/invite_group_to_project_spec.rb @@ -15,9 +15,7 @@ module QA Flow::Login.sign_in(as: user) Page::Dashboard::Projects.perform do |projects| - projects.filter_by_name(project.name) - - expect(projects).to have_project_with_access_role(project.name, 'Developer') + expect(projects).to have_filtered_project_with_access_role(project.name, 'Developer') end project.visit! diff --git a/qa/qa/specs/features/browser_ui/9_data_stores/project/project_owner_permissions_spec.rb b/qa/qa/specs/features/browser_ui/9_data_stores/project/project_owner_permissions_spec.rb index 28d05fd58c0..35e3ea1411b 100644 --- a/qa/qa/specs/features/browser_ui/9_data_stores/project/project_owner_permissions_spec.rb +++ b/qa/qa/specs/features/browser_ui/9_data_stores/project/project_owner_permissions_spec.rb @@ -25,9 +25,7 @@ module QA it "has owner role and permissions", testcase: testcase do Page::Dashboard::Projects.perform do |projects| - projects.filter_by_name(project.name) - - expect(projects).to have_project_with_access_role(project.name, 'Owner') + expect(projects).to have_filtered_project_with_access_role(project.name, 'Owner') end issue.visit! @@ -52,9 +50,7 @@ module QA it "has maintainer role without owner permissions", testcase: testcase do Page::Dashboard::Projects.perform do |projects| - projects.filter_by_name(project.name) - - expect(projects).to have_project_with_access_role(project.name, 'Maintainer') + expect(projects).to have_filtered_project_with_access_role(project.name, 'Maintainer') end issue.visit! diff --git a/scripts/internal_events/cli.rb b/scripts/internal_events/cli.rb new file mode 100755 index 00000000000..6cc9f599608 --- /dev/null +++ b/scripts/internal_events/cli.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +# !/usr/bin/env ruby +# +# Generate a metric/event files in the correct locations. + +require 'tty-prompt' +require 'net/http' +require 'yaml' + +require_relative './cli/helpers' +require_relative './cli/usage_viewer' +require_relative './cli/metric_definer' +require_relative './cli/event_definer' +require_relative './cli/metric' +require_relative './cli/event' +require_relative './cli/text' + +class Cli + include ::InternalEventsCli::Helpers + + attr_reader :cli + + def initialize(cli) + @cli = cli + end + + def run + cli.say InternalEventsCli::Text::FEEDBACK_NOTICE + cli.say InternalEventsCli::Text::CLI_INSTRUCTIONS + + task = cli.select("What would you like to do?", **select_opts) do |menu| + menu.enum "." + + menu.choice "New Event -- track when a specific scenario occurs on gitlab instances\n " \ + "ex) a user applies a label to an issue", :new_event + menu.choice "New Metric -- track the count of existing events over time\n " \ + "ex) count unique users who assign labels to issues per month", :new_metric + menu.choice 'View Usage -- look at code examples for an existing event', :view_usage + menu.choice '...am I in the right place?', :help_decide + end + + case task + when :new_event + InternalEventsCli::EventDefiner.new(cli).run + when :new_metric + InternalEventsCli::MetricDefiner.new(cli).run + when :view_usage + InternalEventsCli::UsageViewer.new(cli).run + when :help_decide + help_decide + end + end + + private + + def help_decide + return use_case_error unless goal_is_tracking_usage? + return use_case_error unless usage_trackable_with_internal_events? + + event_already_tracked? ? proceed_to_metric_definition : proceed_to_event_definition + end + + def goal_is_tracking_usage? + new_page! + + cli.say format_info("First, let's check your objective.\n") + + cli.yes?('Are you trying to track customer usage of a GitLab feature?', **yes_no_opts) + end + + def usage_trackable_with_internal_events? + new_page! + + cli.say format_info("Excellent! Let's check that this tool will fit your needs.\n") + cli.say InternalEventsCli::Text::EVENT_TRACKING_EXAMPLES + + cli.yes?( + 'Can usage for the feature be measured with a count of specific user actions or events? ' \ + 'Or counting a set of events?', **yes_no_opts + ) + end + + def event_already_tracked? + new_page! + + cli.say format_info("Super! Let's figure out if the event is already tracked & usable.\n") + cli.say InternalEventsCli::Text::EVENT_EXISTENCE_CHECK_INSTRUCTIONS + + cli.yes?('Is the event already tracked?', **yes_no_opts) + end + + def use_case_error + new_page! + + cli.error("Oh no! This probably isn't the tool you need!\n") + cli.say InternalEventsCli::Text::ALTERNATE_RESOURCES_NOTICE + cli.say InternalEventsCli::Text::FEEDBACK_NOTICE + end + + def proceed_to_metric_definition + new_page! + + cli.say format_info("Amazing! The next step is adding a new metric! (~8 min)\n") + + return not_ready_error('New Metric') unless cli.yes?(format_prompt('Ready to start?')) + + InternalEventsCli::MetricDefiner.new(cli).run + end + + def proceed_to_event_definition + new_page! + + cli.say format_info("Okay! The next step is adding a new event! (~5 min)\n") + + return not_ready_error('New Event') unless cli.yes?(format_prompt('Ready to start?')) + + InternalEventsCli::EventDefiner.new(cli).run + end + + def not_ready_error(description) + cli.say "\nNo problem! When you're ready, run the CLI & select '#{description}'\n" + cli.say InternalEventsCli::Text::FEEDBACK_NOTICE + end +end + +if $PROGRAM_NAME == __FILE__ + begin + Cli.new(TTY::Prompt.new).run + rescue Interrupt + puts "\n" + end +end + +# vim: ft=ruby diff --git a/scripts/internal_events/cli/event.rb b/scripts/internal_events/cli/event.rb new file mode 100755 index 00000000000..d98aa8a6bd1 --- /dev/null +++ b/scripts/internal_events/cli/event.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module InternalEventsCli + NEW_EVENT_FIELDS = [ + :description, + :category, + :action, + :label_description, + :property_description, + :value_description, + :value_type, + :extra_properties, + :identifiers, + :product_section, + :product_stage, + :product_group, + :milestone, + :introduced_by_url, + :distributions, + :tiers + ].freeze + + EVENT_DEFAULTS = { + product_section: nil, + product_stage: nil, + product_group: nil, + introduced_by_url: 'TODO', + category: 'InternalEventTracking' + }.freeze + + Event = Struct.new(*NEW_EVENT_FIELDS, keyword_init: true) do + def formatted_output + EVENT_DEFAULTS + .merge(to_h.compact) + .slice(*NEW_EVENT_FIELDS) + .transform_keys(&:to_s) + .to_yaml(line_width: 150) + end + + def file_path + File.join( + *[ + ('ee' unless distributions.include?('ce')), + 'config', + 'events', + "#{action}.yml" + ].compact + ) + end + + def bulk_assign(key_value_pairs) + key_value_pairs.each { |key, value| self[key] = value } + end + end +end diff --git a/scripts/internal_events/cli/event_definer.rb b/scripts/internal_events/cli/event_definer.rb new file mode 100755 index 00000000000..e029f0e7cf6 --- /dev/null +++ b/scripts/internal_events/cli/event_definer.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require_relative './helpers' + +module InternalEventsCli + class EventDefiner + include Helpers + + STEPS = [ + 'New Event', + 'Description', + 'Name', + 'Context', + 'URL', + 'Group', + 'Tiers', + 'Save files' + ].freeze + + IDENTIFIER_OPTIONS = { + %w[project namespace user] => 'Use case: For project-level user actions ' \ + '(ex - issue_assignee_changed) [MOST COMMON]', + %w[namespace user] => 'Use case: For namespace-level user actions (ex - epic_assigned_to_milestone)', + %w[user] => 'Use case: For user-only actions (ex - admin_impersonated_user)', + %w[project namespace] => 'Use case: For project-level events without user interaction ' \ + '(ex - service_desk_request_received)', + %w[namespace] => 'Use case: For namespace-level events without user interaction ' \ + '(ex - stale_runners_cleaned_up)', + %w[] => "Use case: For instance-level events without user interaction [LEAST COMMON]" + }.freeze + + IDENTIFIER_FORMATTING_BUFFER = "[#{IDENTIFIER_OPTIONS.keys.max_by(&:length).join(', ')}]".length + + attr_reader :cli, :event + + def initialize(cli) + @cli = cli + @event = Event.new(milestone: MILESTONE) + end + + def run + prompt_for_description + prompt_for_action + prompt_for_identifiers + prompt_for_url + prompt_for_product_ownership + prompt_for_tier + + outcome = create_event_file + display_result(outcome) + + prompt_for_next_steps + end + + private + + def prompt_for_description + new_page!(1, 7, STEPS) + cli.say Text::EVENT_DESCRIPTION_INTRO + + event.description = cli.ask("Describe what the event tracks: #{input_required_text}", **input_opts) do |q| + q.required true + q.modify :trim + q.messages[:required?] = Text::EVENT_DESCRIPTION_HELP + end + end + + def prompt_for_action + new_page!(2, 7, STEPS) + cli.say Text::EVENT_ACTION_INTRO + + event.action = cli.ask("Define the event name: #{input_required_text}", **input_opts) do |q| + q.required true + q.validate ->(input) { input =~ /\A\w+\z/ && !events_by_filepath.values.map(&:action).include?(input) } # rubocop:disable Rails/NegateInclude -- Not rails + q.modify :trim + q.messages[:valid?] = format_warning("Invalid event name. Only letters/numbers/underscores allowed. " \ + "Ensure %{value} is not an existing event.") + q.messages[:required?] = Text::EVENT_ACTION_HELP + end + end + + def prompt_for_identifiers + new_page!(3, 7, STEPS) + cli.say Text::EVENT_IDENTIFIERS_INTRO % event.action + + identifiers = prompt_for_array_selection( + 'Which identifiers are available when the event occurs?', + IDENTIFIER_OPTIONS.keys + ) { |choice| format_identifier_choice(choice) } + + event.identifiers = identifiers if identifiers.any? + end + + def format_identifier_choice(choice) + formatted_choice = choice.empty? ? 'None' : "[#{choice.sort.join(', ')}]" + buffer = IDENTIFIER_FORMATTING_BUFFER - formatted_choice.length + + "#{formatted_choice}#{' ' * buffer} -- #{IDENTIFIER_OPTIONS[choice]}" + end + + def prompt_for_url + new_page!(4, 7, STEPS) + + event.introduced_by_url = prompt_for_text('Which MR URL will merge the event definition?') + end + + def prompt_for_product_ownership + new_page!(5, 7, STEPS) + + ownership = prompt_for_group_ownership({ + product_section: 'Which section will own the event?', + product_stage: 'Which stage will own the event?', + product_group: 'Which group will own the event?' + }) + + event.bulk_assign(ownership) + end + + def prompt_for_tier + new_page!(6, 7, STEPS) + + event.tiers = prompt_for_array_selection( + 'Which tiers will the event be recorded on?', + [%w[free premium ultimate], %w[premium ultimate], %w[ultimate]] + ) + + event.distributions = event.tiers.include?('free') ? %w[ce ee] : %w[ee] + end + + def create_event_file + new_page!(7, 7, STEPS) + + prompt_to_save_file(event.file_path, event.formatted_output) + end + + def display_result(outcome) + new_page! + + cli.say <<~TEXT + #{divider} + #{format_info('Done with event definition!')} + + #{outcome || ' No files saved.'} + + #{divider} + + Want to have data reported in Snowplow/Sisense/ServicePing? Add a new metric for your event! + + TEXT + end + + def prompt_for_next_steps + next_step = cli.select("How would you like to proceed?", **select_opts) do |menu| + menu.enum "." + + if File.exist?(event.file_path) + menu.choice "Create Metric -- define a new metric using #{event.action}.yml", :add_metric + else + menu.choice "Save & Create Metric -- save #{event.action}.yml and define a matching metric", :save_and_add + end + + menu.choice "View Usage -- look at code examples for #{event.action}.yml", :view_usage + menu.choice 'Exit', :exit + end + + case next_step + when :add_metric + MetricDefiner.new(cli, event.file_path).run + when :save_and_add + write_to_file(event.file_path, event.formatted_output, 'create') + + MetricDefiner.new(cli, event.file_path).run + when :view_usage + UsageViewer.new(cli, event.file_path, event).run + when :exit + cli.say Text::FEEDBACK_NOTICE + end + end + end +end diff --git a/scripts/internal_events/cli/helpers.rb b/scripts/internal_events/cli/helpers.rb new file mode 100755 index 00000000000..95672325652 --- /dev/null +++ b/scripts/internal_events/cli/helpers.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative './helpers/cli_inputs' +require_relative './helpers/files' +require_relative './helpers/formatting' +require_relative './helpers/group_ownership' +require_relative './helpers/event_options' +require_relative './helpers/metric_options' + +module InternalEventsCli + module Helpers + include CliInputs + include Files + include Formatting + include GroupOwnership + include EventOptions + include MetricOptions + + MILESTONE = File.read('VERSION').strip.match(/(\d+\.\d+)/).captures.first + + def new_page!(page = nil, total = nil, steps = []) + cli.say TTY::Cursor.clear_screen + cli.say TTY::Cursor.move_to(0, 0) + cli.say "#{progress_bar(page, total, steps)}\n" if page && total + end + end +end diff --git a/scripts/internal_events/cli/helpers/cli_inputs.rb b/scripts/internal_events/cli/helpers/cli_inputs.rb new file mode 100755 index 00000000000..106d854d0b3 --- /dev/null +++ b/scripts/internal_events/cli/helpers/cli_inputs.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# Helpers related to configuration of TTY::Prompt prompts +module InternalEventsCli + module Helpers + module CliInputs + def prompt_for_array_selection(message, choices, default = nil, &formatter) + formatter ||= ->(choice) { choice.sort.join(", ") } + + choices = choices.map do |choice| + { name: formatter.call(choice), value: choice } + end + + cli.select(message, choices, **select_opts) do |menu| + menu.enum "." + menu.default formatter.call(default) if default + end + end + + def prompt_for_text(message, value = nil) + help_message = "(enter to #{value ? 'submit' : 'skip'})" + + cli.ask( + "#{message} #{format_help(help_message)}", + value: value || '', + **input_opts + ) + end + + def input_opts + { prefix: format_prompt('Input text: ') } + end + + def yes_no_opts + { prefix: format_prompt('Yes/No: ') } + end + + def select_opts + { prefix: format_prompt('Select one: '), cycle: true, show_help: :always } + end + + def multiselect_opts + { prefix: format_prompt('Select multiple: '), cycle: true, show_help: :always, min: 1 } + end + + # Accepts a number of lines occupied by text, so remaining + # screen real estate can be filled with select options + def filter_opts(header_size: nil) + { + filter: true, + per_page: header_size ? [(window_height - header_size), 10].max : 30 + } + end + + def input_required_text + format_help("(leave blank for help)") + end + end + end +end diff --git a/scripts/internal_events/cli/helpers/event_options.rb b/scripts/internal_events/cli/helpers/event_options.rb new file mode 100755 index 00000000000..f53127798aa --- /dev/null +++ b/scripts/internal_events/cli/helpers/event_options.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# Helpers related to listing existing event definitions +module InternalEventsCli + module Helpers + module EventOptions + def get_event_options(events) + options = events.filter_map do |(path, event)| + next if duplicate_events?(event.action, events.values) + + description = format_help(" - #{trim_description(event.description)}") + + { + name: "#{format_event_name(event)}#{description}", + value: path + } + end + + options.sort_by do |option| + category = events.dig(option[:value], 'category') + event_sort_param(category, option[:name]) + end + end + + def events_by_filepath(event_paths = []) + event_paths = load_event_paths if event_paths.none? + + get_existing_events_for_paths(event_paths) + end + + private + + def trim_description(description) + return description if description.to_s.length < 50 + + "#{description[0, 50]}..." + end + + def format_event_name(event) + case event.category + when 'InternalEventTracking', 'default' + event.action + else + "#{event.category}:#{event.action}" + end + end + + def event_sort_param(category, name) + case category + when 'InternalEventTracking' + "0#{name}" + when 'default' + "1#{name}" + else + "2#{category}#{name}" + end + end + + def get_existing_events_for_paths(event_paths) + event_paths.each_with_object({}) do |filepath, events| + details = YAML.safe_load(File.read(filepath)) + fields = InternalEventsCli::NEW_EVENT_FIELDS.map(&:to_s) + + events[filepath] = Event.new(**details.slice(*fields)) + end + end + + def duplicate_events?(action, events) + events.count { |event| action == event.action } > 1 + end + + def load_event_paths + [ + Dir["config/events/*.yml"], + Dir["ee/config/events/*.yml"] + ].flatten + end + end + end +end diff --git a/scripts/internal_events/cli/helpers/files.rb b/scripts/internal_events/cli/helpers/files.rb new file mode 100755 index 00000000000..b613350353f --- /dev/null +++ b/scripts/internal_events/cli/helpers/files.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Helpers related reading/writing definition files +module InternalEventsCli + module Helpers + module Files + def prompt_to_save_file(filepath, content) + cli.say <<~TEXT.chomp + #{format_info('Preparing to generate definition with these attributes:')} + #{filepath} + #{content} + TEXT + + if File.exist?(filepath) + cli.error("Oh no! This file already exists!\n") + + return if cli.no?(format_prompt('Overwrite file?')) + + write_to_file(filepath, content, 'update') + elsif cli.yes?(format_prompt('Create file?')) + write_to_file(filepath, content, 'create') + end + end + + def file_saved_message(verb, filepath) + " #{format_selection(verb)} #{filepath}" + end + + def write_to_file(filepath, content, verb) + File.write(filepath, content) + + file_saved_message(verb, filepath).tap { |message| cli.say "\n#{message}\n" } + end + end + end +end diff --git a/scripts/internal_events/cli/helpers/formatting.rb b/scripts/internal_events/cli/helpers/formatting.rb new file mode 100755 index 00000000000..87be585c739 --- /dev/null +++ b/scripts/internal_events/cli/helpers/formatting.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# Helpers related to visual formatting of outputs +module InternalEventsCli + module Helpers + module Formatting + DEFAULT_WINDOW_WIDTH = 100 + DEFAULT_WINDOW_HEIGHT = 30 + + def format_info(string) + pastel.cyan(string) + end + + def format_warning(string) + pastel.yellow(string) + end + + def format_selection(string) + pastel.green(string) + end + + def format_help(string) + pastel.bright_black(string) + end + + def format_prompt(string) + pastel.magenta(string) + end + + def format_error(string) + pastel.red(string) + end + + def format_heading(string) + [divider, pastel.cyan(string), divider].join("\n") + end + + def divider + "-" * window_size + end + + def progress_bar(step, total, titles = []) + breadcrumbs = [ + titles[0..(step - 1)], + format_selection(titles[step]), + titles[(step + 1)..] + ] + + status = " Step #{step} / #{total} : #{breadcrumbs.flatten.join(' > ')}" + total_length = window_size - 4 + step_length = step / total.to_f * total_length + + incomplete = '-' * [(total_length - step_length - 1), 0].max + complete = '=' * [(step_length - 1), 0].max + "#{status}\n|==#{complete}>#{incomplete}|\n" + end + + def counter(idx, total) + format_prompt("(#{idx + 1}/#{total})") if total > 1 + end + + private + + def pastel + @pastel ||= Pastel.new + end + + def window_size + Integer(fetch_window_size) + rescue StandardError + DEFAULT_WINDOW_WIDTH + end + + def window_height + Integer(fetch_window_height) + rescue StandardError + DEFAULT_WINDOW_HEIGHT + end + + def fetch_window_size + `tput cols` + end + + def fetch_window_height + `tput lines` + end + end + end +end diff --git a/scripts/internal_events/cli/helpers/group_ownership.rb b/scripts/internal_events/cli/helpers/group_ownership.rb new file mode 100755 index 00000000000..9846f0ca2f5 --- /dev/null +++ b/scripts/internal_events/cli/helpers/group_ownership.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Helpers related to Stage/Section/Group ownership +module InternalEventsCli + module Helpers + module GroupOwnership + STAGES_YML = 'https://gitlab.com/gitlab-com/www-gitlab-com/-/raw/master/data/stages.yml' + + def prompt_for_group_ownership(messages, defaults = {}) + groups = fetch_group_choices + + if groups + prompt_for_ownership_from_ssot(messages[:product_group], defaults, groups) + else + prompt_for_ownership_manually(messages, defaults) + end + end + + private + + def prompt_for_ownership_from_ssot(prompt, defaults, groups) + sorted_defaults = defaults.values_at(:product_section, :product_stage, :product_group) + default = sorted_defaults.join(':') + + cli.select(prompt, groups, **select_opts, **filter_opts) do |menu| + if sorted_defaults.all? + if groups.any? { |group| group[:name] == default } + # We have a complete group selection -> set as default in menu + menu.default(default) + else + cli.error format_error(">>> Failed to find group matching #{default}. Select another.\n") + end + elsif sorted_defaults.any? + # We have a partial selection -> filter the list by the most unique field + menu.instance_variable_set(:@filter, sorted_defaults.compact.last.split('')) + end + end + end + + def prompt_for_ownership_manually(messages, defaults) + { + product_section: prompt_for_text(messages[:product_section], defaults[:product_section]), + product_stage: prompt_for_text(messages[:product_stage], defaults[:product_stage]), + product_group: prompt_for_text(messages[:product_group], defaults[:product_group]) + } + end + + # @return Array[<Hash - matches #prompt_for_ownership_manually output format>] + def fetch_group_choices + response = Timeout.timeout(5) { Net::HTTP.get(URI(STAGES_YML)) } + stages = YAML.safe_load(response) + + stages['stages'].flat_map do |stage, value| + value['groups'].map do |group, _| + section = value['section'] + + { + name: [section, stage, group].join(':'), + value: { + product_group: group, + product_section: section, + product_stage: stage + } + } + end + end + rescue StandardError + end + end + end +end diff --git a/scripts/internal_events/cli/helpers/metric_options.rb b/scripts/internal_events/cli/helpers/metric_options.rb new file mode 100755 index 00000000000..01512115e05 --- /dev/null +++ b/scripts/internal_events/cli/helpers/metric_options.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +# Helpers related to listing existing metric definitions +module InternalEventsCli + module Helpers + module MetricOptions + EVENT_PHRASES = { + 'user' => "who triggered %s", + 'namespace' => "where %s occurred", + 'project' => "where %s occurred", + nil => "%s occurrences" + }.freeze + + def get_metric_options(events) + options = get_all_metric_options + identifiers = get_identifiers_for_events(events) + existing_metrics = get_existing_metrics_for_events(events) + metric_name = format_metric_name_for_events(events) + + options = options.group_by do |metric| + [ + metric.identifier, + metric_already_exists?(existing_metrics, metric), + metric.time_frame == 'all' + ] + end + + options.map do |(identifier, defined, _), metrics| + format_metric_option( + identifier, + metric_name, + metrics, + defined: defined, + supported: [*identifiers, nil].include?(identifier) + ) + end + end + + private + + def get_all_metric_options + [ + Metric.new(time_frame: '28d', identifier: 'user'), + Metric.new(time_frame: '7d', identifier: 'user'), + Metric.new(time_frame: '28d', identifier: 'project'), + Metric.new(time_frame: '7d', identifier: 'project'), + Metric.new(time_frame: '28d', identifier: 'namespace'), + Metric.new(time_frame: '7d', identifier: 'namespace'), + Metric.new(time_frame: '28d'), + Metric.new(time_frame: '7d'), + Metric.new(time_frame: 'all') + ] + end + + def load_metric_paths + [ + Dir["config/metrics/counts_all/*.yml"], + Dir["config/metrics/counts_7d/*.yml"], + Dir["config/metrics/counts_28d/*.yml"], + Dir["ee/config/metrics/counts_all/*.yml"], + Dir["ee/config/metrics/counts_7d/*.yml"], + Dir["ee/config/metrics/counts_28d/*.yml"] + ].flatten + end + + def get_existing_metrics_for_events(events) + actions = events.map(&:action).sort + + load_metric_paths.filter_map do |path| + details = YAML.safe_load(File.read(path)) + fields = InternalEventsCli::NEW_METRIC_FIELDS.map(&:to_s) + + metric = Metric.new(**details.slice(*fields)) + next unless metric.actions + + metric if (metric.actions & actions).any? + end + end + + def format_metric_name_for_events(events) + return events.first.action if events.length == 1 + + "any of #{events.length} events" + end + + # Get only the identifiers in common for all events + def get_identifiers_for_events(events) + events.map(&:identifiers).reduce(&:&) || [] + end + + def metric_already_exists?(existing_metrics, metric) + existing_metrics.any? do |existing_metric| + time_frame = existing_metric.time_frame || 'all' + identifier = existing_metric.events&.dig(0, 'unique')&.chomp('.id') + + metric.time_frame == time_frame && metric.identifier == identifier + end + end + + def format_metric_option(identifier, event_name, metrics, defined:, supported:) + time_frame = metrics.map(&:time_frame_prefix).join('/') + unique_by = "unique #{identifier}s " if identifier + event_phrase = EVENT_PHRASES[identifier] % event_name + + if supported && !defined + time_frame = format_info(time_frame) + unique_by = format_info(unique_by) + end + + name = "#{time_frame} count of #{unique_by}[#{event_phrase}]" + + if supported && defined + disabled = format_warning("(already defined)") + name = format_help(name) + elsif !supported + disabled = format_warning("(#{identifier} unavailable)") + name = format_help(name) + end + + { name: name, value: metrics, disabled: disabled }.compact + end + end + end +end diff --git a/scripts/internal_events/cli/metric.rb b/scripts/internal_events/cli/metric.rb new file mode 100755 index 00000000000..63961d29810 --- /dev/null +++ b/scripts/internal_events/cli/metric.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +module InternalEventsCli + NEW_METRIC_FIELDS = [ + :key_path, + :description, + :product_section, + :product_stage, + :product_group, + :performance_indicator_type, + :value_type, + :status, + :milestone, + :introduced_by_url, + :time_frame, + :data_source, + :data_category, + :product_category, + :instrumentation_class, + :distribution, + :tier, + :options, + :events + ].freeze + + ADDITIONAL_METRIC_FIELDS = [ + :milestone_removed, + :removed_by_url, + :removed_by, + :repair_issue_url, + :value_json_schema, + :name + ].freeze + + METRIC_DEFAULTS = { + product_section: nil, + product_stage: nil, + product_group: nil, + introduced_by_url: 'TODO', + value_type: 'number', + status: 'active', + data_source: 'internal_events', + data_category: 'optional', + performance_indicator_type: [] + }.freeze + + Metric = Struct.new(*NEW_METRIC_FIELDS, *ADDITIONAL_METRIC_FIELDS, :identifier, keyword_init: true) do + def formatted_output + METRIC_DEFAULTS + .merge(to_h.compact) + .merge( + key_path: key_path, + instrumentation_class: instrumentation_class, + events: events) + .slice(*NEW_METRIC_FIELDS) + .transform_keys(&:to_s) + .to_yaml(line_width: 150) + end + + def file_path + File.join( + *[ + ('ee' unless distribution.include?('ce')), + 'config', + 'metrics', + "counts_#{time_frame}", + "#{key}.yml" + ].compact + ) + end + + def key + [ + 'count', + (identifier ? "distinct_#{identifier}_id_from" : 'total'), + actions.join('_and_'), + (time_frame_prefix&.downcase if time_frame != 'all') + ].compact.join('_') + end + + def key_path + self[:key_path] ||= "#{key_path_prefix}.#{key}" + end + + def instrumentation_class + self[:instrumentation_class] ||= identifier ? 'RedisHLLMetric' : 'TotalCountMetric' + end + + def events + self[:events] ||= actions.map do |action| + if identifier + { + 'name' => action, + 'unique' => "#{identifier}.id" + } + else + { 'name' => action } + end + end + end + + def key_path_prefix + case instrumentation_class + when 'RedisHLLMetric' + 'redis_hll_counters' + when 'TotalCountMetric' + 'counts' + end + end + + def actions + options&.dig('events')&.sort || [] + end + + def identifier_prefix + if identifier + "count of unique #{identifier}s" + else + "count of" + end + end + + def time_frame_prefix + case time_frame + when '7d' + 'Weekly' + when '28d' + 'Monthly' + when 'all' + 'Total' + end + end + + def prefix + [time_frame_prefix, identifier_prefix].join(' ') + end + + def technical_description + simple_event_list = actions.join(' or ') + + case identifier + when 'user' + "#{prefix} who triggered #{simple_event_list}" + when 'project', 'namespace' + "#{prefix} where #{simple_event_list} occurred" + else + "#{prefix} #{simple_event_list} occurrences" + end + end + + def bulk_assign(key_value_pairs) + key_value_pairs.each { |key, value| self[key] = value } + end + end +end diff --git a/scripts/internal_events/cli/metric_definer.rb b/scripts/internal_events/cli/metric_definer.rb new file mode 100755 index 00000000000..7688f03200f --- /dev/null +++ b/scripts/internal_events/cli/metric_definer.rb @@ -0,0 +1,310 @@ +# frozen_string_literal: true + +require_relative './helpers' + +module InternalEventsCli + class MetricDefiner + include Helpers + + STEPS = [ + 'New Metric', + 'Type', + 'Events', + 'Scope', + 'Descriptions', + 'Copy event', + 'Group', + 'URL', + 'Tiers', + 'Save files' + ].freeze + + attr_reader :cli + + def initialize(cli, starting_event = nil) + @cli = cli + @selected_event_paths = Array(starting_event) + @metrics = [] + end + + def run + type = prompt_for_metric_type + prompt_for_events(type) + + return unless @selected_event_paths.any? + + prompt_for_metrics + + return unless @metrics.any? + + prompt_for_description + defaults = prompt_for_copying_event_properties + prompt_for_product_ownership(defaults) + prompt_for_url(defaults) + prompt_for_tier(defaults) + outcomes = create_metric_files + prompt_for_next_steps(outcomes) + end + + private + + def events + @events ||= events_by_filepath(@selected_event_paths) + end + + def selected_events + @selected_events ||= events.values_at(*@selected_event_paths) + end + + def prompt_for_metric_type + return if @selected_event_paths.any? + + new_page!(1, 9, STEPS) + + cli.select("Which best describes what the metric should track?", **select_opts) do |menu| + menu.enum "." + + menu.choice 'Single event -- count occurrences of a specific event or user interaction', :event_metric + menu.choice 'Multiple events -- count occurrences of several separate events or interactions', :aggregate_metric + menu.choice 'Database -- record value of a particular field or count of database rows', :database_metric + end + end + + def prompt_for_events(type) + return if @selected_event_paths.any? + + new_page!(2, 9, STEPS) + + case type + when :event_metric + cli.say "For robust event search, use the Metrics Dictionary: https://metrics.gitlab.com/snowplow\n\n" + + @selected_event_paths = [cli.select( + 'Which event does this metric track?', + get_event_options(events), + **select_opts, + **filter_opts(header_size: 7) + )] + when :aggregate_metric + cli.say "For robust event search, use the Metrics Dictionary: https://metrics.gitlab.com/snowplow\n\n" + + @selected_event_paths = cli.multi_select( + 'Which events does this metric track? (Space to select)', + get_event_options(events), + **multiselect_opts, + **filter_opts(header_size: 7) + ) + when :database_metric + cli.error Text::DATABASE_METRIC_NOTICE + cli.say Text::FEEDBACK_NOTICE + end + end + + def prompt_for_metrics + eligible_metrics = get_metric_options(selected_events) + + if eligible_metrics.all? { |metric| metric[:disabled] } + cli.error Text::ALL_METRICS_EXIST_NOTICE + cli.say Text::FEEDBACK_NOTICE + + return + end + + new_page!(3, 9, STEPS) + + @metrics = cli.select('Which metrics do you want to add?', eligible_metrics, **select_opts) + + assign_shared_attrs(:options, :milestone) do + { + options: { 'events' => selected_events.map(&:action) }, + milestone: MILESTONE + } + end + end + + def prompt_for_description + new_page!(4, 9, STEPS) + + cli.say Text::METRIC_DESCRIPTION_INTRO + cli.say selected_event_descriptions.join('') + + base_description = nil + + @metrics.each_with_index do |metric, idx| + multiline_prompt = [ + counter(idx, @metrics.length), + format_prompt("Complete the text:"), + "How would you describe this metric to a non-technical person?", + input_required_text, + "\n\n Technical description: #{metric.technical_description}" + ].compact.join(' ') + + last_line_of_prompt = "\n Finish the description: #{format_info("#{metric.prefix}...")}" + + cli.say("\n") + cli.say(multiline_prompt) + + description_help_message = [ + Text::METRIC_DESCRIPTION_HELP, + multiline_prompt, + "\n\n" + ].join("\n") + + # Reassign base_description so the next metric's default value is their own input + base_description = cli.ask(last_line_of_prompt, value: base_description.to_s) do |q| + q.required true + q.modify :trim + q.messages[:required?] = description_help_message + end + + cli.say("\n") # looks like multiline input, but isn't. Spacer improves clarity. + + metric.description = "#{metric.prefix} #{base_description}" + end + end + + def selected_event_descriptions + @selected_event_descriptions ||= selected_events.map do |event| + " #{event.action} - #{format_selection(event.description)}\n" + end + end + + # Check existing event files for attributes to copy over + def prompt_for_copying_event_properties + defaults = collect_values_for_shared_event_properties + + return {} if defaults.none? + + new_page!(5, 9, STEPS) + + cli.say <<~TEXT + #{format_info('Convenient! We can copy these attributes from the event definition(s):')} + + #{defaults.compact.transform_keys(&:to_s).to_yaml(line_width: 150)} + #{format_info('If any of these attributes are incorrect, you can also change them manually from your text editor later.')} + + TEXT + + cli.select('What would you like to do?', **select_opts) do |menu| + menu.enum '.' + menu.choice 'Copy & continue', -> { bulk_assign(defaults) } + menu.choice 'Modify attributes' + end + + defaults + end + + def collect_values_for_shared_event_properties + fields = Hash.new { |h, k| h[k] = [] } + + selected_events.each do |event| + fields[:introduced_by_url] << event.introduced_by_url + fields[:product_section] << event.product_section + fields[:product_stage] << event.product_stage + fields[:product_group] << event.product_group + fields[:distribution] << event.distributions&.sort + fields[:tier] << event.tiers&.sort + end + + # Keep event values if every selected event is the same + fields.each_with_object({}) do |(attr, values), defaults| + next unless values.compact.uniq.length == 1 + + defaults[attr] ||= values.first + end + end + + def prompt_for_product_ownership(defaults) + assign_shared_attrs(:product_section, :product_stage, :product_group) do + new_page!(6, 9, STEPS) + + prompt_for_group_ownership( + { + product_section: 'Which section owns the metric?', + product_stage: 'Which stage owns the metric?', + product_group: 'Which group owns the metric?' + }, + defaults.slice(:product_section, :product_stage, :product_group) + ) + end + end + + def prompt_for_url(defaults) + assign_shared_attr(:introduced_by_url) do + new_page!(7, 9, STEPS) + + prompt_for_text( + "Which MR URL introduced the metric?", + defaults[:introduced_by_url] + ) + end + end + + def prompt_for_tier(defaults) + assign_shared_attr(:tier) do + new_page!(8, 9, STEPS) + + prompt_for_array_selection( + 'Which tiers will the metric be reported from?', + [%w[free premium ultimate], %w[premium ultimate], %w[ultimate]], + defaults[:tier] + ) + end + + assign_shared_attr(:distribution) do |metric| + metric.tier.include?('free') ? %w[ce ee] : %w[ee] + end + end + + def create_metric_files + @metrics.map.with_index do |metric, idx| + new_page!(9, 9, STEPS) # Repeat the same step number but increment metric counter + + cli.say format_prompt("SAVING FILE #{counter(idx, @metrics.length)}: #{metric.technical_description}\n") + + prompt_to_save_file(metric.file_path, metric.formatted_output) + end + end + + def prompt_for_next_steps(outcomes = []) + new_page! + + outcome = outcomes.any? ? outcomes.compact.join("\n") : ' No files saved.' + + cli.say <<~TEXT + #{divider} + #{format_info('Done with metric definitions!')} + + #{outcome} + + #{divider} + TEXT + + cli.select("How would you like to proceed?", **select_opts) do |menu| + menu.enum "." + menu.choice "View Usage -- look at code examples for #{@selected_event_paths.first}", -> do + UsageViewer.new(cli, @selected_event_paths.first, selected_events.first).run + end + menu.choice 'Exit', -> { cli.say Text::FEEDBACK_NOTICE } + end + end + + def assign_shared_attrs(...) + metric = @metrics.first + attrs = metric.to_h.slice(...) + attrs = yield(metric) unless attrs.values.all? + + bulk_assign(attrs) + end + + def assign_shared_attr(key) + assign_shared_attrs(key) do |metric| + { key => yield(metric) } + end + end + + def bulk_assign(attrs) + @metrics.each { |metric| metric.bulk_assign(attrs) } + end + end +end diff --git a/scripts/internal_events/cli/text.rb b/scripts/internal_events/cli/text.rb new file mode 100755 index 00000000000..4cb1cc23326 --- /dev/null +++ b/scripts/internal_events/cli/text.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +# Blocks of text rendered in CLI +module InternalEventsCli + module Text + extend Helpers + + CLI_INSTRUCTIONS = <<~TEXT.freeze + #{format_info('INSTRUCTIONS:')} + To start tracking usage of a feature... + + 1) Define event (using CLI) + 2) Trigger event (from code) + 3) Define metric (using CLI) + 4) View data in Sisense (after merge & deploy) + + This CLI will help you create the correct defintion files, then provide code examples for instrumentation and testing. + + Learn more: https://docs.gitlab.com/ee/development/internal_analytics/#fundamental-concepts + + TEXT + + # TODO: Remove "NEW TOOL" comment after 3 months + FEEDBACK_NOTICE = format_heading <<~TEXT.chomp + Thanks for using the Internal Events CLI! + + Please reach out with any feedback! + About Internal Events: https://gitlab.com/gitlab-org/analytics-section/analytics-instrumentation/internal/-/issues/687 + About CLI: https://gitlab.com/gitlab-org/gitlab/-/issues/434038 + In Slack: #g_analyze_analytics_instrumentation + + Let us know that you used the CLI! React with 👍 on the feedback issue or post in Slack! + TEXT + + ALTERNATE_RESOURCES_NOTICE = <<~TEXT.freeze + Other resources: + + #{format_warning('Tracking GitLab feature usage from database info:')} + https://docs.gitlab.com/ee/development/internal_analytics/metrics/metrics_instrumentation.html#database-metrics + + #{format_warning('Migrating existing metrics to use Internal Events:')} + https://docs.gitlab.com/ee/development/internal_analytics/internal_event_instrumentation/migration.html + + #{format_warning('Remove an existing metric:')} + https://docs.gitlab.com/ee/development/internal_analytics/metrics/metrics_lifecycle.html + + #{format_warning('Finding existing usage data for GitLab features:')} + https://metrics.gitlab.com/ (Customize Table > Sisense query) + https://app.periscopedata.com/app/gitlab/1049395/Service-Ping-Exploration-Dashboard + + #{format_warning('Customer wants usage data for their own GitLab instance:')} + https://docs.gitlab.com/ee/user/analytics/ + + #{format_warning('Customer wants usage data for their own products:')} + https://docs.gitlab.com/ee/user/product_analytics/ + TEXT + + EVENT_TRACKING_EXAMPLES = <<~TEXT + Product usage can be tracked in several ways. + + By tracking events: ex) a user changes the assignee on an issue + ex) a user uploads a CI template + ex) a service desk request is received + ex) all stale runners are cleaned up + ex) a user copies code to the clipboard from markdown + ex) a user uploads an issue template OR a user uploads an MR template + + From database data: ex) track whether each gitlab instance allows signups + ex) query how many projects are on each gitlab instance + + TEXT + + EVENT_EXISTENCE_CHECK_INSTRUCTIONS = <<~TEXT.freeze + To determine what to do next, let's figure out if the event is already tracked & usable. + + If you're unsure whether an event exists, you can check the existing defintions. + + #{format_info('FROM GDK')}: Check `config/events/` or `ee/config/events` + #{format_info('FROM BROWSER')}: Check https://metrics.gitlab.com/snowplow + + Find one? Create a new metric for the event. + Otherwise? Create a new event. + + If you find a relevant event that has a different category from 'InternalEventTracking', it can be migrated to + Internal Events. See https://docs.gitlab.com/ee/development/internal_analytics/internal_event_instrumentation/migration.html + + TEXT + + EVENT_DESCRIPTION_INTRO = <<~TEXT.freeze + #{format_info('EVENT DESCRIPTION')} + Include what the event is supposed to track, where, and when. + + The description field helps others find & reuse this event. This will be used by Engineering, Product, Data team, Support -- and also GitLab customers directly. Be specific and explicit. + ex - Debian package published to the registry using a deploy token + ex - Issue confidentiality was changed + + TEXT + + EVENT_DESCRIPTION_HELP = <<~TEXT.freeze + #{format_warning('Required. 10+ words likely, but length may vary.')} + + #{format_info('GOOD EXAMPLES:')} + - Pipeline is created with a CI Template file included in its configuration + - Quick action `/assign @user1` used to assign a single individual to an issuable + - Quick action `/target_branch` used on a Merge Request + - Quick actions `/unlabel` or `/remove_label` used to remove one or more specific labels + - User edits file using the single file editor + - User edits file using the Web IDE + - User removed issue link between issue and incident + - Debian package published to the registry using a deploy token + + #{format_info('GUT CHECK:')} + For your description... + 1. Would two different engineers likely instrument the event from the same code locations? + 2. Would a new GitLab user find where the event is triggered in the product? + 3. Would a GitLab customer understand what the description says? + + + TEXT + + EVENT_ACTION_INTRO = <<~TEXT.freeze + #{format_info('EVENT NAME')} + The event name is a unique identifier used from both a) app code and b) metric definitions. + The name should concisely communicate the same information as the event description. + + ex - change_time_estimate_on_issue + ex - push_package_to_repository + ex - publish_go_module_to_the_registry_from_pipeline + ex - admin_user_comments_on_issue_while_impersonating_blocked_user + + #{format_info('EXPECTED FORMAT:')} #{format_selection('<action>_<target_of_action>_<where/when>')} + + ex) click_save_button_in_issue_description_within_15s_of_page_load + - TARGET: save button + - ACTION: click + - WHERE: in issue description + - WHEN: within 15s of page load + + TEXT + + EVENT_ACTION_HELP = <<~TEXT.freeze + #{format_warning('Required. Must be globally unique. Must use only letters/numbers/underscores.')} + + #{format_info('FAQs:')} + - Q: Present tense or past tense? + A: Prefer present tense! But it's up to you. + - Q: Other event names have prefixes like `i_` or the `g_group_name`. Why? + A: Those are leftovers from legacy naming schemes. Changing the names of old events/metrics can break dashboards, so stability is better than uniformity. + + + TEXT + + EVENT_IDENTIFIERS_INTRO = <<~TEXT.freeze + #{format_info('EVENT CONTEXT')} + Identifies the attributes recorded when the event occurs. Generally, we want to include every identifier available to us when the event is triggered. + + #{format_info('BACKEND')}: Attributes must be specified when the event is triggered + ex) If the backend event was instrumentuser/project/namespace are the identifiers for this backend instrumentation: + + Gitlab::InternalEvents.track_event( + '%s', + user: user, + project: project, + namespace: project.namespace + ) + + #{format_info('FRONTEND')}: Attributes are automatically included from the URL + ex) When a user takes an action on the MR list page, the URL is https://gitlab.com/gitlab-org/gitlab/-/merge_requests + Because this URL is for a project, we know that all of user/project/namespace are available for the event + + #{format_info('NOTE')}: If you're planning to instrument a unique-by-user metric, you should still include project & namespace when possible. This is especially helpful in the data warehouse, where namespace and project can make events relevant for CSM use-cases. + + TEXT + + DATABASE_METRIC_NOTICE = <<~TEXT + + For right now, this script can only define metrics for internal events. + + For more info on instrumenting database-backed metrics, see https://docs.gitlab.com/ee/development/internal_analytics/metrics/metrics_instrumentation.html + TEXT + + ALL_METRICS_EXIST_NOTICE = <<~TEXT + + Looks like the potential metrics for this event either already exist or are unsupported. + + Check out https://metrics.gitlab.com/ for improved event/metric search capabilities. + TEXT + + METRIC_DESCRIPTION_INTRO = <<~TEXT.freeze + #{format_info('METRIC DESCRIPTION')} + Describes which occurrences of an event are tracked in the metric and how they're grouped. + + The description field is critical for helping others find & reuse this event. This will be used by Engineering, Product, Data team, Support -- and also GitLab customers directly. Be specific and explicit. + + #{format_info('GOOD EXAMPLES:')} + - Total count of analytics dashboard list views + - Weekly count of unique users who viewed the analytics dashboard list + - Monthly count of unique projects where the analytics dashboard list was viewed + - Total count of issue updates + + #{format_info('SELECTED EVENT(S):')} + TEXT + + METRIC_DESCRIPTION_HELP = <<~TEXT.freeze + #{format_warning('Required. 10+ words likely, but length may vary.')} + + An event description can often be rearranged to work as a metric description. + + ex) Event description: A merge request was created + Metric description: Total count of merge requests created + Metric description: Weekly count of unqiue users who created merge requests + + Look at the event descriptions above to get ideas! + TEXT + end +end diff --git a/scripts/internal_events/cli/usage_viewer.rb b/scripts/internal_events/cli/usage_viewer.rb new file mode 100755 index 00000000000..6a9be38e25d --- /dev/null +++ b/scripts/internal_events/cli/usage_viewer.rb @@ -0,0 +1,247 @@ +# frozen_string_literal: true + +require_relative './helpers' + +module InternalEventsCli + class UsageViewer + include Helpers + + IDENTIFIER_EXAMPLES = { + %w[namespace project user] => { "namespace" => "project.namespace" }, + %w[namespace user] => { "namespace" => "group" } + }.freeze + + attr_reader :cli, :event + + def initialize(cli, event_path = nil, event = nil) + @cli = cli + @event = event + @selected_event_path = event_path + end + + def run + prompt_for_eligible_event + prompt_for_usage_location + end + + def prompt_for_eligible_event + return if event + + event_details = events_by_filepath + + @selected_event_path = cli.select( + "Show examples for which event?", + get_event_options(event_details), + **select_opts, + **filter_opts + ) + + @event = event_details[@selected_event_path] + end + + def prompt_for_usage_location(default = 'ruby/rails') + choices = [ + { name: 'ruby/rails', value: :rails }, + { name: 'rspec', value: :rspec }, + { name: 'javascript (vue)', value: :vue }, + { name: 'javascript (plain)', value: :js }, + { name: 'vue template', value: :vue_template }, + { name: 'haml', value: :haml }, + { name: 'View examples for a different event', value: :other_event }, + { name: 'Exit', value: :exit } + ] + + usage_location = cli.select( + 'Select a use-case to view examples for:', + choices, + **select_opts, + per_page: 10 + ) do |menu| + menu.enum '.' + menu.default default + end + + case usage_location + when :rails + rails_examples + prompt_for_usage_location('ruby/rails') + when :rspec + rspec_examples + prompt_for_usage_location('rspec') + when :haml + haml_examples + prompt_for_usage_location('haml') + when :js + js_examples + prompt_for_usage_location('javascript (plain)') + when :vue + vue_examples + prompt_for_usage_location('javascript (vue)') + when :vue_template + vue_template_examples + prompt_for_usage_location('vue template') + when :other_event + self.class.new(cli).run + when :exit + cli.say(Text::FEEDBACK_NOTICE) + end + end + + def rails_examples + args = Array(event['identifiers']).map do |identifier| + " #{identifier}: #{identifier_examples[identifier]}" + end + action = args.any? ? "\n '#{event['action']}',\n" : "'#{event['action']}'" + + cli.say format_warning <<~TEXT + #{divider} + #{format_help('# RAILS')} + + Gitlab::InternalEvents.track_event(#{action}#{args.join(",\n")}#{"\n" unless args.empty?}) + + #{divider} + TEXT + end + + def rspec_examples + cli.say format_warning <<~TEXT + #{divider} + #{format_help('# RSPEC')} + + it_behaves_like 'internal event tracking' do + let(:event) { '#{event['action']}' } + #{ + Array(event['identifiers']).map do |identifier| + " let(:#{identifier}) { #{identifier_examples[identifier]} }\n" + end.join('') + }end + + #{divider} + TEXT + end + + def identifier_examples + event['identifiers'] + .to_h { |identifier| [identifier, identifier] } + .merge(IDENTIFIER_EXAMPLES[event['identifiers'].sort] || {}) + end + + def haml_examples + cli.say <<~TEXT + #{divider} + #{format_help('# HAML -- ON-CLICK')} + + .gl-display-inline-block{ #{format_warning("data: { event_tracking: '#{event['action']}' }")} } + = _('Important Text') + + #{divider} + #{format_help('# HAML -- COMPONENT ON-CLICK')} + + = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { event_tracking: '#{event['action']}' }")} }) + + #{divider} + #{format_help('# HAML -- COMPONENT ON-LOAD')} + + = render Pajamas::ButtonComponent.new(button_options: { #{format_warning("data: { event_tracking_load: true, event_tracking: '#{event['action']}' }")} }) + + #{divider} + TEXT + + cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") + end + + def vue_template_examples + cli.say <<~TEXT + #{divider} + #{format_help('// VUE TEMPLATE -- ON-CLICK')} + + <script> + import { GlButton } from '@gitlab/ui'; + + export default { + components: { GlButton } + }; + </script> + + <template> + <gl-button #{format_warning("data-event-tracking=\"#{event['action']}\"")}> + Click Me + </gl-button> + </template> + + #{divider} + #{format_help('// VUE TEMPLATE -- ON-LOAD')} + + <script> + import { GlButton } from '@gitlab/ui'; + + export default { + components: { GlButton } + }; + </script> + + <template> + <gl-button #{format_warning("data-event-tracking-load=\"#{event['action']}\"")}> + Click Me + </gl-button> + </template> + + #{divider} + TEXT + + cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") + end + + def js_examples + cli.say <<~TEXT + #{divider} + #{format_help('// FRONTEND -- RAW JAVASCRIPT')} + + #{format_warning("import { InternalEvents } from '~/tracking';")} + + export const performAction = () => { + #{format_warning("InternalEvents.trackEvent('#{event['action']}');")} + + return true; + }; + + #{divider} + TEXT + + # https://docs.snowplow.io/docs/understanding-your-pipeline/schemas/ + cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") + end + + def vue_examples + cli.say <<~TEXT + #{divider} + #{format_help('// VUE')} + + <script> + #{format_warning("import { InternalEvents } from '~/tracking';")} + import { GlButton } from '@gitlab/ui'; + + #{format_warning('const trackingMixin = InternalEvents.mixin();')} + + export default { + #{format_warning('mixins: [trackingMixin]')}, + components: { GlButton }, + methods: { + performAction() { + #{format_warning("this.trackEvent('#{event['action']}');")} + }, + }, + }; + </script> + + <template> + <gl-button @click=performAction>Click Me</gl-button> + </template> + + #{divider} + TEXT + + cli.say("Want to see the implementation details? See app/assets/javascripts/tracking/internal_events.js\n\n") + end + end +end diff --git a/spec/fixtures/scripts/internal_events/events/ee_event_without_identifiers.yml b/spec/fixtures/scripts/internal_events/events/ee_event_without_identifiers.yml new file mode 100644 index 00000000000..07f606fbe33 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/events/ee_event_without_identifiers.yml @@ -0,0 +1,14 @@ +--- +description: Internal Event CLI is opened +category: InternalEventTracking +action: internal_events_cli_opened +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +milestone: '16.6' +introduced_by_url: TODO +distributions: +- ee +tiers: +- premium +- ultimate diff --git a/spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml b/spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml new file mode 100644 index 00000000000..5050953920d --- /dev/null +++ b/spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml @@ -0,0 +1,20 @@ +--- +description: Engineer uses Internal Event CLI to define a new event +category: InternalEventTracking +action: internal_events_cli_used +identifiers: +- project +- namespace +- user +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +milestone: '16.6' +introduced_by_url: TODO +distributions: +- ce +- ee +tiers: +- free +- premium +- ultimate diff --git a/spec/fixtures/scripts/internal_events/events/keyboard_smashed_event.yml b/spec/fixtures/scripts/internal_events/events/keyboard_smashed_event.yml new file mode 100644 index 00000000000..c0ccbc03af7 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/events/keyboard_smashed_event.yml @@ -0,0 +1,20 @@ +--- +description: random event string +category: InternalEventTracking +action: random_name +identifiers: +- project +- namespace +- user +product_section: core_platform +product_stage: manage +product_group: import_and_integrate +milestone: '16.6' +introduced_by_url: TODO +distributions: +- ce +- ee +tiers: +- free +- premium +- ultimate diff --git a/spec/fixtures/scripts/internal_events/events/secondary_event_with_identifiers.yml b/spec/fixtures/scripts/internal_events/events/secondary_event_with_identifiers.yml new file mode 100644 index 00000000000..4e2e77e0c5c --- /dev/null +++ b/spec/fixtures/scripts/internal_events/events/secondary_event_with_identifiers.yml @@ -0,0 +1,20 @@ +--- +description: Engineer closes Internal Event CLI +category: InternalEventTracking +action: internal_events_cli_closed +identifiers: +- project +- namespace +- user +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +milestone: '16.6' +introduced_by_url: TODO +distributions: +- ce +- ee +tiers: +- free +- premium +- ultimate diff --git a/spec/fixtures/scripts/internal_events/metrics/ee_total_28d_single_event.yml b/spec/fixtures/scripts/internal_events/metrics/ee_total_28d_single_event.yml new file mode 100644 index 00000000000..ba56d782871 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/ee_total_28d_single_event.yml @@ -0,0 +1,25 @@ +--- +key_path: counts.count_total_internal_events_cli_used_monthly +description: Monthly count of when an event was defined using the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 28d +data_source: internal_events +data_category: optional +instrumentation_class: TotalCountMetric +distribution: +- ee +tier: +- premium +- ultimate +options: + events: + - internal_events_cli_used +events: +- name: internal_events_cli_used diff --git a/spec/fixtures/scripts/internal_events/metrics/ee_total_7d_single_event.yml b/spec/fixtures/scripts/internal_events/metrics/ee_total_7d_single_event.yml new file mode 100644 index 00000000000..e6bdcb9d2ae --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/ee_total_7d_single_event.yml @@ -0,0 +1,25 @@ +--- +key_path: counts.count_total_internal_events_cli_used_weekly +description: Weekly count of when an event was defined using the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 7d +data_source: internal_events +data_category: optional +instrumentation_class: TotalCountMetric +distribution: +- ee +tier: +- premium +- ultimate +options: + events: + - internal_events_cli_used +events: +- name: internal_events_cli_used diff --git a/spec/fixtures/scripts/internal_events/metrics/ee_total_single_event.yml b/spec/fixtures/scripts/internal_events/metrics/ee_total_single_event.yml new file mode 100644 index 00000000000..b1bf89dc095 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/ee_total_single_event.yml @@ -0,0 +1,25 @@ +--- +key_path: counts.count_total_internal_events_cli_used +description: Total count of when an event was defined using the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: all +data_source: internal_events +data_category: optional +instrumentation_class: TotalCountMetric +distribution: +- ee +tier: +- premium +- ultimate +options: + events: + - internal_events_cli_used +events: +- name: internal_events_cli_used diff --git a/spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_28d.yml b/spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_28d.yml new file mode 100644 index 00000000000..8476cb8561b --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_28d.yml @@ -0,0 +1,28 @@ +--- +key_path: redis_hll_counters.count_distinct_user_id_from_random_name_monthly +description: Monthly count of unique users random metric string +product_section: core_platform +product_stage: manage +product_group: import_and_integrate +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 28d +data_source: internal_events +data_category: optional +instrumentation_class: RedisHLLMetric +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - random_name +events: +- name: random_name + unique: user.id diff --git a/spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_7d.yml b/spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_7d.yml new file mode 100644 index 00000000000..b4cc2fc8b55 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_7d.yml @@ -0,0 +1,28 @@ +--- +key_path: redis_hll_counters.count_distinct_user_id_from_random_name_weekly +description: Weekly count of unique users random metric string +product_section: core_platform +product_stage: manage +product_group: import_and_integrate +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 7d +data_source: internal_events +data_category: optional +instrumentation_class: RedisHLLMetric +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - random_name +events: +- name: random_name + unique: user.id diff --git a/spec/fixtures/scripts/internal_events/metrics/project_id_28d_multiple_events.yml b/spec/fixtures/scripts/internal_events/metrics/project_id_28d_multiple_events.yml new file mode 100644 index 00000000000..754702c8c74 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/project_id_28d_multiple_events.yml @@ -0,0 +1,31 @@ +--- +key_path: redis_hll_counters.count_distinct_project_id_from_internal_events_cli_closed_and_internal_events_cli_used_monthly +description: Monthly count of unique projects where a defition file was created with the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 28d +data_source: internal_events +data_category: optional +instrumentation_class: RedisHLLMetric +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - internal_events_cli_closed + - internal_events_cli_used +events: +- name: internal_events_cli_closed + unique: project.id +- name: internal_events_cli_used + unique: project.id diff --git a/spec/fixtures/scripts/internal_events/metrics/project_id_7d_multiple_events.yml b/spec/fixtures/scripts/internal_events/metrics/project_id_7d_multiple_events.yml new file mode 100644 index 00000000000..95f429e9b40 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/project_id_7d_multiple_events.yml @@ -0,0 +1,31 @@ +--- +key_path: redis_hll_counters.count_distinct_project_id_from_internal_events_cli_closed_and_internal_events_cli_used_weekly +description: Weekly count of unique projects where a defition file was created with the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 7d +data_source: internal_events +data_category: optional +instrumentation_class: RedisHLLMetric +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - internal_events_cli_closed + - internal_events_cli_used +events: +- name: internal_events_cli_closed + unique: project.id +- name: internal_events_cli_used + unique: project.id diff --git a/spec/fixtures/scripts/internal_events/metrics/total_single_event.yml b/spec/fixtures/scripts/internal_events/metrics/total_single_event.yml new file mode 100644 index 00000000000..5bdb4c45a52 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/total_single_event.yml @@ -0,0 +1,27 @@ +--- +key_path: counts.count_total_internal_events_cli_used +description: Total count of when an event was defined using the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: all +data_source: internal_events +data_category: optional +instrumentation_class: TotalCountMetric +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - internal_events_cli_used +events: +- name: internal_events_cli_used diff --git a/spec/fixtures/scripts/internal_events/metrics/user_id_28d_single_event.yml b/spec/fixtures/scripts/internal_events/metrics/user_id_28d_single_event.yml new file mode 100644 index 00000000000..b176b23b46a --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/user_id_28d_single_event.yml @@ -0,0 +1,28 @@ +--- +key_path: redis_hll_counters.count_distinct_user_id_from_internal_events_cli_used_monthly +description: Monthly count of unique users who defined an internal event using the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 28d +data_source: internal_events +data_category: optional +instrumentation_class: RedisHLLMetric +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - internal_events_cli_used +events: +- name: internal_events_cli_used + unique: user.id diff --git a/spec/fixtures/scripts/internal_events/metrics/user_id_7d_single_event.yml b/spec/fixtures/scripts/internal_events/metrics/user_id_7d_single_event.yml new file mode 100644 index 00000000000..8a0fca2cbdc --- /dev/null +++ b/spec/fixtures/scripts/internal_events/metrics/user_id_7d_single_event.yml @@ -0,0 +1,28 @@ +--- +key_path: redis_hll_counters.count_distinct_user_id_from_internal_events_cli_used_weekly +description: Weekly count of unique users who defined an internal event using the CLI +product_section: analytics +product_stage: monitor +product_group: analytics_instrumentation +performance_indicator_type: [] +value_type: number +status: active +milestone: '16.6' +introduced_by_url: TODO +time_frame: 7d +data_source: internal_events +data_category: optional +instrumentation_class: RedisHLLMetric +distribution: +- ce +- ee +tier: +- free +- premium +- ultimate +options: + events: + - internal_events_cli_used +events: +- name: internal_events_cli_used + unique: user.id diff --git a/spec/fixtures/scripts/internal_events/new_events.yml b/spec/fixtures/scripts/internal_events/new_events.yml new file mode 100644 index 00000000000..6f39fc5e93c --- /dev/null +++ b/spec/fixtures/scripts/internal_events/new_events.yml @@ -0,0 +1,183 @@ +- description: Creates a new event and flows directly into metric creation + inputs: + keystrokes: + - "1\n" # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + - "Engineer uses Internal Event CLI to define a new event\n" # Submit description + - "internal_events_cli_used\n" # Submit action name + - "1\n" # Select: [namespace, project, user] + - "\n" # Skip MR URL + - "instrumentation" # Filters to the analytics instrumentation group + - "\n" # Accept analytics:monitor:analytics_instrumentation + - "1\n" # Select: [free, premium, ultimate] + - "y\n" # Create file + - "1\n" # Select: Create Metric --- define a new metric + - "\e[A" # Arrow up to: Total count of events + - "\n" # Select: Total count of events + - "when an event was defined using the CLI\n" # Input description + - "1\n" # Select: Copy & continue + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + - path: config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/total_single_event.yml + +- description: Requires description & action before continuing + inputs: + keystrokes: + - "1\n" # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + - "\n" # Attempt to skip writing description --> should get help message + - "Engineer uses Internal Event CLI to define a new event\n" # Submit description + - "\n" # Attempt to skip naming action --> should get help message + - "internal_events_cli_used\n" # Submit action name + - "1\n" # Select [namespace, project, user] + - "\n" # Skip MR URL + - "instrumentation" # Filters to the analytics instrumentation group + - "\n" # Accept analytics:monitor:analytics_instrumentation + - "1\n" # Select [free, premium, ultimate] + - "y\n" # Create file + - "3\n" # Exit + outputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + +- description: Does not allow existing events for action + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + keystrokes: + - "1\n" # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + - "Engineer closes Internal Event CLI\n" # Submit description + - "internal_events_cli_used\n" # Submit already-existing action name + - "internal_events_cli_closed\n" # Submit alterred action name + - "1\n" # Select [namespace, project, user] + - "\n" # Skip MR URL + - "instrumentation" # Filters to the analytics instrumentation group + - "\n" # Accept analytics:monitor:analytics_instrumentation + - "1\n" # Select [free, premium, ultimate] + - "y\n" # Create file + - "3\n" # Exit + outputs: + files: + - path: config/events/internal_events_cli_closed.yml + content: spec/fixtures/scripts/internal_events/events/secondary_event_with_identifiers.yml + +- description: Creates a new event without identifiers + inputs: + keystrokes: + - "1\n" # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + - "Internal Event CLI is opened\n" # Submit description + - "internal_events_cli_opened\n" # Submit action name + - "6\n" # Select: None + - "\n" # Skip MR URL + - "instrumentation" # Filters to the analytics instrumentation group + - "\n" # Accept analytics:monitor:analytics_instrumentation + - "2\n" # Select [premium, ultimate] + - "y\n" # Create file + - "3\n" # Exit + outputs: + files: + - path: ee/config/events/internal_events_cli_opened.yml + content: spec/fixtures/scripts/internal_events/events/ee_event_without_identifiers.yml + +- description: Smashing the keyboard/return creates an event & metrics with the most common attributes, then shows usage + inputs: + keystrokes: + - "\n" # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + - "random event string\n" # Submit keyboard-smashing description + - "random_name\n" # Submit keyboard-smashing action name + - "\n" # Select: [namespace, project, user] + - "\n" # Skip MR URL + - "\n" # Select core_platform:manage:import_and_integrate + - "\n" # Select [free, premium, ultimate] + - "\n" # Create file + - "\n" # Select: Create Metric --- define a new metric + - "\n" # Select: Weekly/Monthly count of unique users + - "random metric string\n" # Submit keyboard-smashing description + - "\n" # Accept weekly description for monthly + - "\n" # Select: Copy & continue + - "\n" # Skip URL + - "\n" # Create file + - "\n" # Create file + - "\n" # Select: View Usage -- look at code examples + - "\n" # Select: Ruby/Rails + - "8\n" # Exit + outputs: + files: + - path: config/events/random_name.yml + content: spec/fixtures/scripts/internal_events/events/keyboard_smashed_event.yml + - path: config/metrics/counts_28d/count_distinct_user_id_from_random_name_monthly.yml + content: spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_28d.yml + - path: config/metrics/counts_7d/count_distinct_user_id_from_random_name_weekly.yml + content: spec/fixtures/scripts/internal_events/metrics/keyboard_smashed_metric_7d.yml + +- description: Creates an event after helping the user figure out next steps + inputs: + keystrokes: + - "4\n" # Enum-select: ...am I in the right place? + - "y\n" # Yes --> Are you trying to track customer usage of a GitLab feature? + - "y\n" # Yes --> Can usage for the feature be measured by tracking a specific user action? + - "n\n" # No --> Is the event already tracked? + - "y\n" # Yes --> Ready to start? + - "Internal Event CLI is opened\n" # Submit description + - "internal_events_cli_opened\n" # Submit action name + - "6\n" # Select: None + - "\n" # Skip MR URL + - "instrumentation" # Filters to the analytics instrumentation group + - "\n" # Accept analytics:monitor:analytics_instrumentation + - "2\n" # Select [premium, ultimate] + - "y\n" # Create file + - "3\n" # Exit + outputs: + files: + - path: ee/config/events/internal_events_cli_opened.yml + content: spec/fixtures/scripts/internal_events/events/ee_event_without_identifiers.yml + +- description: Creates a new event and flows directly into usage examples + inputs: + keystrokes: + - "1\n" # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + - "Engineer uses Internal Event CLI to define a new event\n" # Submit description + - "internal_events_cli_used\n" # Submit action name + - "1\n" # Select: [namespace, project, user] + - "\n" # Skip MR URL + - "instrumentation" # Filters to the analytics instrumentation group + - "\n" # Accept analytics:monitor:analytics_instrumentation + - "1\n" # Select: [free, premium, ultimate] + - "y\n" # Create file + - "2\n" # Select: View Usage + - "8\n" # Exit + outputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + +- description: Skips event creation, then saves event & flows directly into metric creation + inputs: + keystrokes: + - "1\n" # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + - "Engineer uses Internal Event CLI to define a new event\n" # Submit description + - "internal_events_cli_used\n" # Submit action name + - "1\n" # Select: [namespace, project, user] + - "\n" # Skip MR URL + - "instrumentation" # Filters to the analytics instrumentation group + - "\n" # Accept analytics:monitor:analytics_instrumentation + - "1\n" # Select: [free, premium, ultimate] + - "n\n" # Create file + - "1\n" # Select: Save event & create Metric --- define a new metric + - "\e[A" # Arrow up to: Total count of events + - "\n" # Select: Total count of events + - "when an event was defined using the CLI\n" # Input description + - "1\n" # Select: Copy & continue + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + - path: config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/total_single_event.yml diff --git a/spec/fixtures/scripts/internal_events/new_metrics.yml b/spec/fixtures/scripts/internal_events/new_metrics.yml new file mode 100644 index 00000000000..2a207ee84f4 --- /dev/null +++ b/spec/fixtures/scripts/internal_events/new_metrics.yml @@ -0,0 +1,196 @@ +- description: Create a weekly/monthly metric for a single event + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "1\n" # Enum-select: Single event -- count occurrences of a specific event or user interaction + - 'internal_events_cli_used' # Filters to this event + - "\n" # Select: config/events/internal_events_cli_used.yml + - "\n" # Select: Weekly count of unique users + - "who defined an internal event using the CLI\n" # Input description + - "\n" # Submit weekly description for monthly + - "1\n" # Enum-select: Copy & continue + - "y\n" # Create file + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: config/metrics/counts_28d/count_distinct_user_id_from_internal_events_cli_used_monthly.yml + content: spec/fixtures/scripts/internal_events/metrics/user_id_28d_single_event.yml + - path: config/metrics/counts_7d/count_distinct_user_id_from_internal_events_cli_used_weekly.yml + content: spec/fixtures/scripts/internal_events/metrics/user_id_7d_single_event.yml + +- description: Create a weekly/monthly metric for a multiple events, but select only one event + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "2\n" # Enum-select: Multiple events -- count occurrences of several separate events or interactions + - 'internal_events_cli_used' # Filters to this event + - " " # Multi-select: config/events/internal_events_cli_used.yml + - "\n" # Submit selections + - "\n" # Select: Weekly count of unique projects + - "who defined an internal event using the CLI\n" # Input description + - "\n" # Submit weekly description for monthly + - "1\n" # Enum-select: Copy & continue + - "y\n" # Create file + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: config/metrics/counts_28d/count_distinct_user_id_from_internal_events_cli_used_monthly.yml + content: spec/fixtures/scripts/internal_events/metrics/user_id_28d_single_event.yml + - path: config/metrics/counts_7d/count_distinct_user_id_from_internal_events_cli_used_weekly.yml + content: spec/fixtures/scripts/internal_events/metrics/user_id_7d_single_event.yml + +- description: Create a weekly/monthly metric for multiple events + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + - path: config/events/internal_events_cli_closed.yml + content: spec/fixtures/scripts/internal_events/events/secondary_event_with_identifiers.yml + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "2\n" # Enum-select: Multiple events -- count occurrences of several separate events or interactions + - 'internal_events_cli' # Filters to the relevant events + - ' ' # Multi-select: internal_events_cli_closed + - "\e[B" # Arrow down to: internal_events_cli_used + - ' ' # Multi-select: internal_events_cli_used + - "\n" # Submit selections + - "\e[B" # Arrow down to: Weekly count of unique projects + - "\n" # Select: Weekly count of unique projects + - "where a defition file was created with the CLI\n" # Input description + - "\n" # Submit weekly description for monthly + - "1\n" # Select: Copy & continue + - "y\n" # Create file + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: config/metrics/counts_28d/count_distinct_project_id_from_internal_events_cli_closed_and_internal_events_cli_used_monthly.yml + content: spec/fixtures/scripts/internal_events/metrics/project_id_28d_multiple_events.yml + - path: config/metrics/counts_7d/count_distinct_project_id_from_internal_events_cli_closed_and_internal_events_cli_used_weekly.yml + content: spec/fixtures/scripts/internal_events/metrics/project_id_7d_multiple_events.yml + +- description: Create an all time total metric for a single event + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "1\n" # Enum-select: Single event -- count occurrences of a specific event or user interaction + - 'internal_events_cli_used' # Filters to this event + - "\n" # Select: config/events/internal_events_cli_used.yml + - "\e[A" # Arrow up to: Total count of events + - "\n" # Select: Total count of events + - "when an event was defined using the CLI\n" # Input description + - "1\n" # Select: Copy & continue + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/total_single_event.yml + +- description: Try to create a database metric + inputs: + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "3\n" # Enum-select: Database -- record value of a particular field or count of database rows + +- description: Create an all time total metric for a single event, and confirm each attribute copied from event + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "1\n" # Enum-select: Single event -- count occurrences of a specific event or user interaction + - 'internal_events_cli_used' # Filters to this event + - "\n" # Select: config/events/internal_events_cli_used.yml + - "\e[A" # Arrow up to: Total count of events + - "\n" # Select: Total count of events + - "when an event was defined using the CLI\n" # Input description + - "2\n" # Enum-select: Modify attributes + - "\n" # Accept group/section/stage from event definition + - "\n" # Accept URL from event definition + - "2\n" # Override tier -> Select: [premium, ultimate] + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: ee/config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/ee_total_single_event.yml + +- description: Create a metric after helping the user figure out next steps + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + keystrokes: + - "4\n" # Enum-select: ...am I in the right place? + - "y\n" # Yes --> Are you trying to track customer usage of a GitLab feature? + - "y\n" # Yes --> Can usage for the feature be measured by tracking a specific user action? + - "y\n" # Yes --> Is the event already tracked? + - "y\n" # Yes --> Ready to start? + - "1\n" # Enum-select: Single event -- count occurrences of a specific event or user interaction + - 'internal_events_cli_used' # Filters to this event + - "\n" # Select: config/events/internal_events_cli_used.yml + - "\e[A" # Arrow up to: Total count of events + - "\n" # Select: Total count of events + - "when an event was defined using the CLI\n" # Input description + - "1\n" # Select: Copy & continue + - "y\n" # Create file + - "2\n" # Exit + outputs: + files: + - path: config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/total_single_event.yml + +- description: User overwrites metric that already exists + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + - path: config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/ee_total_7d_single_event.yml # wrong content + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "1\n" # Enum-select: Single event -- count occurrences of a specific event or user interaction + - 'internal_events_cli_used' # Filters to this event + - "\n" # Select: config/events/internal_events_cli_used.yml + - "\e[A" # Arrow up to: Total count of events + - "\n" # Select: Total count of events + - "when an event was defined using the CLI\n" # Input description + - "1\n" # Select: Copy & continue + - "y\n" # Overwrite file + - "2\n" # Exit + outputs: + files: + - path: config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/total_single_event.yml + +- description: User opts not to overwrite metric that already exists + inputs: + files: + - path: config/events/internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/events/event_with_identifiers.yml + - path: config/metrics/counts_all/count_total_internal_events_cli_used.yml + content: spec/fixtures/scripts/internal_events/metrics/ee_total_7d_single_event.yml # wrong content + keystrokes: + - "2\n" # Enum-select: New Metric -- calculate how often one or more existing events occur over time + - "1\n" # Enum-select: Single event -- count occurrences of a specific event or user interaction + - 'internal_events_cli_used' # Filters to this event + - "\n" # Select: config/events/internal_events_cli_used.yml + - "\e[A" # Arrow up to: Total count of events + - "\n" # Select: Total count of events + - "when an event was defined using the CLI\n" # Input description + - "1\n" # Select: Copy & continue + - "n\n" # Don't overwrite file + - "2\n" # Exit diff --git a/spec/fixtures/scripts/internal_events/stages.yml b/spec/fixtures/scripts/internal_events/stages.yml new file mode 100644 index 00000000000..d5db9dcbe6d --- /dev/null +++ b/spec/fixtures/scripts/internal_events/stages.yml @@ -0,0 +1,78 @@ +stages: + manage: + display_name: "Manage" + section: core_platform + groups: + import_and_integrate: + name: Import and Integrate + foundations: + name: Foundations + + plan: + display_name: "Plan" + section: dev + groups: + project_management: + name: Project Management + product_planning: + name: Product Planning + knowledge: + name: Knowledge + optimize: + name: Optimize + + create: + display_name: "Create" + section: dev + slack: + channel: s_create + groups: + source_code: + name: Source Code + code_review: + name: Code Review + ide: + name: IDE + editor_extensions: + name: Editor Extensions + code_creation: + name: Code Creation + + verify: + display_name: "Verify" + section: ci + slack: + channel: s_verify + groups: + pipeline_execution: + name: "Pipeline Execution" + pipeline_authoring: + name: "Pipeline Authoring" + runner: + name: "Runner" + runner_saas: + name: "Runner SaaS" + pipeline_security: + name: "Pipeline Security" + + package: + display_name: "Package" + section: ci + slack: + channel: s_package + groups: + package_registry: + name: Package Registry + container_registry: + name: Container Registry + + monitor: + display_name: Monitor + section: analytics + groups: + analytics_instrumentation: + name: Analytics Instrumentation + product_analytics: + name: Product Analytics + observability: + name: "Observability" diff --git a/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_spec.js b/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_spec.js index f0174cd7b4a..bc7aa8ef5de 100644 --- a/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_spec.js +++ b/spec/frontend/admin/abuse_report/components/notes/abuse_report_note_spec.js @@ -93,6 +93,26 @@ describe('Abuse Report Note', () => { }); describe('Editing', () => { + it('should show edit button when resolveNote is true', () => { + createComponent({ + note: { ...mockNote, userPermissions: { resolveNote: true } }, + }); + + expect(findNoteActions().props()).toMatchObject({ + showEditButton: true, + }); + }); + + it('should not show edit button when resolveNote is false', () => { + createComponent({ + note: { ...mockNote, userPermissions: { resolveNote: false } }, + }); + + expect(findNoteActions().props()).toMatchObject({ + showEditButton: false, + }); + }); + it('should not be in edit mode by default', () => { expect(findEditNote().exists()).toBe(false); }); @@ -164,16 +184,14 @@ describe('Abuse Report Note', () => { }); }); - describe('Actions', () => { - it('should show note actions', () => { - expect(findNoteActions().exists()).toBe(true); + describe('Replying', () => { + it('should show reply button', () => { expect(findNoteActions().props()).toMatchObject({ - showReplyButton: mockShowReplyButton, - showEditButton: true, + showReplyButton: true, }); }); - it('should emit `startReplying`', () => { + it('should bubble up `startReplying` event', () => { findNoteActions().vm.$emit('startReplying'); expect(wrapper.emitted('startReplying')).toHaveLength(1); diff --git a/spec/frontend/admin/abuse_report/mock_data.js b/spec/frontend/admin/abuse_report/mock_data.js index a958c4d30b1..9790b44c976 100644 --- a/spec/frontend/admin/abuse_report/mock_data.js +++ b/spec/frontend/admin/abuse_report/mock_data.js @@ -153,7 +153,7 @@ export const mockDiscussionWithNoReplies = [ }, lastEditedBy: null, userPermissions: { - adminNote: true, + resolveNote: true, __typename: 'NotePermissions', }, discussion: { @@ -192,7 +192,7 @@ export const mockDiscussionWithReplies = [ }, lastEditedBy: null, userPermissions: { - adminNote: true, + resolveNote: true, __typename: 'NotePermissions', }, discussion: { @@ -237,7 +237,7 @@ export const mockDiscussionWithReplies = [ }, lastEditedBy: null, userPermissions: { - adminNote: true, + resolveNote: true, __typename: 'NotePermissions', }, discussion: { @@ -282,7 +282,7 @@ export const mockDiscussionWithReplies = [ }, lastEditedBy: null, userPermissions: { - adminNote: true, + resolveNote: true, __typename: 'NotePermissions', }, discussion: { @@ -368,7 +368,7 @@ export const createAbuseReportNoteResponse = { }, lastEditedBy: null, userPermissions: { - adminNote: true, + resolveNote: true, }, discussion: { id: 'gid://gitlab/Discussion/90ca230051611e6e1676c50ba7178e0baeabd98d', @@ -413,7 +413,7 @@ export const editAbuseReportNoteResponse = { }, lastEditedBy: 'root', userPermissions: { - adminNote: true, + resolveNote: true, __typename: 'NotePermissions', }, }, diff --git a/spec/frontend/deploy_keys/graphql/resolvers_spec.js b/spec/frontend/deploy_keys/graphql/resolvers_spec.js new file mode 100644 index 00000000000..458232697cb --- /dev/null +++ b/spec/frontend/deploy_keys/graphql/resolvers_spec.js @@ -0,0 +1,249 @@ +import MockAdapter from 'axios-mock-adapter'; +import { HTTP_STATUS_OK, HTTP_STATUS_NOT_FOUND } from '~/lib/utils/http_status'; +import axios from '~/lib/utils/axios_utils'; +import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql'; +import currentPageQuery from '~/deploy_keys/graphql/queries/current_page.query.graphql'; +import currentScopeQuery from '~/deploy_keys/graphql/queries/current_scope.query.graphql'; +import confirmRemoveKeyQuery from '~/deploy_keys/graphql/queries/confirm_remove_key.query.graphql'; +import { resolvers } from '~/deploy_keys/graphql/resolvers'; + +const ENDPOINTS = { + enabledKeysEndpoint: '/enabled_keys', + availableProjectKeysEndpoint: '/available_project_keys', + availablePublicKeysEndpoint: '/available_public_keys', +}; + +describe('~/deploy_keys/graphql/resolvers', () => { + let mockResolvers; + let mock; + let client; + + beforeEach(() => { + mockResolvers = resolvers(ENDPOINTS); + mock = new MockAdapter(axios); + client = { + writeQuery: jest.fn(), + readQuery: jest.fn(), + readFragment: jest.fn(), + cache: { evict: jest.fn(), gc: jest.fn() }, + }; + }); + + afterEach(() => { + mock.reset(); + }); + + describe('deployKeys', () => { + const key = { id: 1, title: 'hello', edit_path: '/edit' }; + + it.each(['enabledKeys', 'availableProjectKeys', 'availablePublicKeys'])( + 'should request the endpoint for the %s scope', + async (scope) => { + mock.onGet(ENDPOINTS[`${scope}Endpoint`]).reply(HTTP_STATUS_OK, { keys: [key] }); + + const keys = await mockResolvers.Project.deployKeys(null, { scope, page: 1 }, { client }); + + expect(keys).toEqual([ + { id: 1, title: 'hello', editPath: '/edit', __typename: 'LocalDeployKey' }, + ]); + }, + ); + + it('should default to enabled keys if a bad scope is given', async () => { + const scope = 'bad'; + mock.onGet(ENDPOINTS.enabledKeysEndpoint).reply(HTTP_STATUS_OK, { keys: [key] }); + + const keys = await mockResolvers.Project.deployKeys(null, { scope, page: 1 }, { client }); + + expect(keys).toEqual([ + { id: 1, title: 'hello', editPath: '/edit', __typename: 'LocalDeployKey' }, + ]); + }); + + it('should request the given page', async () => { + const scope = 'enabledKeys'; + const page = 2; + mock + .onGet(ENDPOINTS.enabledKeysEndpoint, { params: { page } }) + .reply(HTTP_STATUS_OK, { keys: [key] }); + + const keys = await mockResolvers.Project.deployKeys(null, { scope, page }, { client }); + + expect(keys).toEqual([ + { id: 1, title: 'hello', editPath: '/edit', __typename: 'LocalDeployKey' }, + ]); + }); + + it('should write pagination info to the cache', async () => { + const scope = 'enabledKeys'; + const page = 1; + + mock.onGet(ENDPOINTS.enabledKeysEndpoint).reply( + HTTP_STATUS_OK, + { keys: [key] }, + { + 'x-next-page': '2', + 'x-page': '1', + 'X-Per-Page': '2', + 'X-Prev-Page': '', + 'X-TOTAL': '37', + 'X-Total-Pages': '5', + }, + ); + + await mockResolvers.Project.deployKeys(null, { scope, page }, { client }); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: pageInfoQuery, + variables: { input: { scope, page } }, + data: { + pageInfo: { + total: 37, + perPage: 2, + previousPage: NaN, + totalPages: 5, + nextPage: 2, + page: 1, + __typename: 'LocalPageInfo', + }, + }, + }); + }); + + it('should not write page info if the request fails', async () => { + const scope = 'enabledKeys'; + const page = 1; + + mock.onGet(ENDPOINTS.enabledKeysEndpoint).reply(HTTP_STATUS_NOT_FOUND); + + try { + await mockResolvers.Project.deployKeys(null, { scope, page }, { client }); + } catch { + expect(client.writeQuery).not.toHaveBeenCalled(); + } + }); + }); + + describe('currentPage', () => { + it('sets the current page', () => { + const page = 5; + mockResolvers.Mutation.currentPage(null, { page }, { client }); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: currentPageQuery, + data: { currentPage: page }, + }); + }); + }); + + describe('currentScope', () => { + let scope; + + beforeEach(() => { + scope = 'enabledKeys'; + mockResolvers.Mutation.currentScope(null, { scope }, { client }); + }); + + it('sets the current scope', () => { + expect(client.writeQuery).toHaveBeenCalledWith({ + query: currentScopeQuery, + data: { currentScope: scope }, + }); + }); + + it('resets the page to 1', () => { + expect(client.writeQuery).toHaveBeenCalledWith({ + query: currentPageQuery, + data: { currentPage: 1 }, + }); + }); + }); + + describe('disableKey', () => { + it('disables the key that is pending confirmation', async () => { + const key = { id: 1, title: 'hello', disablePath: '/disable', __typename: 'LocalDeployKey' }; + client.readQuery.mockReturnValue({ deployKeyToRemove: key }); + client.readFragment.mockReturnValue(key); + mock.onPut(key.disablePath).reply(HTTP_STATUS_OK); + await mockResolvers.Mutation.disableKey(null, null, { client }); + + expect(client.readQuery).toHaveBeenCalledWith({ query: confirmRemoveKeyQuery }); + expect(client.readFragment).toHaveBeenCalledWith( + expect.objectContaining({ id: `LocalDeployKey:${key.id}` }), + ); + expect(client.cache.evict).toHaveBeenCalledWith({ fieldName: 'deployKeyToRemove' }); + expect(client.cache.evict).toHaveBeenCalledWith({ id: `LocalDeployKey:${key.id}` }); + expect(client.cache.gc).toHaveBeenCalled(); + }); + + it('does not remove the key from the cache on fail', async () => { + const key = { id: 1, title: 'hello', disablePath: '/disable', __typename: 'LocalDeployKey' }; + client.readQuery.mockReturnValue({ deployKeyToRemove: key }); + client.readFragment.mockReturnValue(key); + mock.onPut(key.disablePath).reply(HTTP_STATUS_NOT_FOUND); + + try { + await mockResolvers.Mutation.disableKey(null, null, { client }); + } catch { + expect(client.readQuery).toHaveBeenCalledWith({ query: confirmRemoveKeyQuery }); + expect(client.readFragment).toHaveBeenCalledWith( + expect.objectContaining({ id: `LocalDeployKey:${key.id}` }), + ); + expect(client.cache.evict).not.toHaveBeenCalled(); + expect(client.cache.gc).not.toHaveBeenCalled(); + } + }); + }); + + describe('enableKey', () => { + it("calls the key's enable path", async () => { + const key = { id: 1, title: 'hello', enablePath: '/enable', __typename: 'LocalDeployKey' }; + client.readQuery.mockReturnValue({ deployKeyToRemove: key }); + client.readFragment.mockReturnValue(key); + mock.onPut(key.enablePath).reply(HTTP_STATUS_OK); + await mockResolvers.Mutation.enableKey(null, key, { client }); + + expect(client.readFragment).toHaveBeenCalledWith( + expect.objectContaining({ id: `LocalDeployKey:${key.id}` }), + ); + expect(client.cache.evict).toHaveBeenCalledWith({ id: `LocalDeployKey:${key.id}` }); + expect(client.cache.gc).toHaveBeenCalled(); + }); + + it('does not remove the key from the cache on failure', async () => { + const key = { id: 1, title: 'hello', enablePath: '/enable', __typename: 'LocalDeployKey' }; + client.readQuery.mockReturnValue({ deployKeyToRemove: key }); + client.readFragment.mockReturnValue(key); + mock.onPut(key.enablePath).reply(HTTP_STATUS_NOT_FOUND); + try { + await mockResolvers.Mutation.enableKey(null, key, { client }); + } catch { + expect(client.readFragment).toHaveBeenCalledWith( + expect.objectContaining({ id: `LocalDeployKey:${key.id}` }), + ); + expect(client.cache.evict).not.toHaveBeenCalled(); + expect(client.cache.gc).not.toHaveBeenCalled(); + } + }); + }); + + describe('confirmDisable', () => { + it('sets the key to disable', () => { + const key = { id: 1, title: 'hello', enablePath: '/enable', __typename: 'LocalDeployKey' }; + mockResolvers.Mutation.confirmDisable(null, key, { client }); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: confirmRemoveKeyQuery, + data: { deployKeyToRemove: { id: key.id, __type: 'LocalDeployKey' } }, + }); + }); + it('clears the value when null id is passed', () => { + mockResolvers.Mutation.confirmDisable(null, { id: null }, { client }); + + expect(client.writeQuery).toHaveBeenCalledWith({ + query: confirmRemoveKeyQuery, + data: { deployKeyToRemove: null }, + }); + }); + }); +}); diff --git a/spec/frontend/environments/graphql/resolvers/base_spec.js b/spec/frontend/environments/graphql/resolvers/base_spec.js index f78146fe48e..939ccc0780c 100644 --- a/spec/frontend/environments/graphql/resolvers/base_spec.js +++ b/spec/frontend/environments/graphql/resolvers/base_spec.js @@ -9,7 +9,7 @@ import environmentToStopQuery from '~/environments/graphql/queries/environment_t import createMockApollo from 'helpers/mock_apollo_helper'; import pollIntervalQuery from '~/environments/graphql/queries/poll_interval.query.graphql'; import isEnvironmentStoppingQuery from '~/environments/graphql/queries/is_environment_stopping.query.graphql'; -import pageInfoQuery from '~/environments/graphql/queries/page_info.query.graphql'; +import pageInfoQuery from '~/graphql_shared/client/page_info.query.graphql'; import { TEST_HOST } from 'helpers/test_constants'; import { environmentsApp, diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index 364fe733a41..94d888bb067 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -5,10 +5,10 @@ import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { makeMockUserCalloutDismisser } from 'helpers/mock_user_callout_dismisser'; import stubChildren from 'helpers/stub_children'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import SecurityConfigurationApp, { i18n } from '~/security_configuration/components/app.vue'; +import SecurityConfigurationApp from '~/security_configuration/components/app.vue'; import AutoDevopsAlert from '~/security_configuration/components/auto_dev_ops_alert.vue'; import AutoDevopsEnabledAlert from '~/security_configuration/components/auto_dev_ops_enabled_alert.vue'; -import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from '~/security_configuration/components/constants'; +import { AUTO_DEVOPS_ENABLED_ALERT_DISMISSED_STORAGE_KEY } from '~/security_configuration/constants'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; import { securityFeaturesMock, provideMock } from '../mock_data'; @@ -19,6 +19,8 @@ const { vulnerabilityTrainingDocsPath, projectFullPath } = provideMock; useLocalStorageSpy(); Vue.use(VueApollo); +const { i18n } = SecurityConfigurationApp; + describe('~/security_configuration/components/app', () => { let wrapper; let userCalloutDismissSpy; diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js index 983a66a7fd3..9efee2a409a 100644 --- a/spec/frontend/security_configuration/components/feature_card_spec.js +++ b/spec/frontend/security_configuration/components/feature_card_spec.js @@ -1,7 +1,7 @@ import { GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import { securityFeatures } from '~/security_configuration/components/constants'; +import { securityFeatures } from '~/security_configuration/constants'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue'; import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js index 5b2b3f46df6..ef20d8f56a4 100644 --- a/spec/frontend/security_configuration/components/training_provider_list_spec.js +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -19,8 +19,8 @@ import { TRACK_TOGGLE_TRAINING_PROVIDER_LABEL, TRACK_PROVIDER_LEARN_MORE_CLICK_ACTION, TRACK_PROVIDER_LEARN_MORE_CLICK_LABEL, + TEMP_PROVIDER_URLS, } from '~/security_configuration/constants'; -import { TEMP_PROVIDER_URLS } from '~/security_configuration/components/constants'; import TrainingProviderList from '~/security_configuration/components/training_provider_list.vue'; import { updateSecurityTrainingOptimisticResponse } from '~/security_configuration/graphql/cache_utils'; import securityTrainingProvidersQuery from '~/security_configuration/graphql/security_training_providers.query.graphql'; @@ -61,10 +61,9 @@ const TEMP_PROVIDER_LOGOS = { svg: '<svg>Secure Code Warrior</svg>', }, }; -jest.mock('~/security_configuration/components/constants', () => { +jest.mock('~/security_configuration/constants', () => { return { - TEMP_PROVIDER_URLS: jest.requireActual('~/security_configuration/components/constants') - .TEMP_PROVIDER_URLS, + TEMP_PROVIDER_URLS: jest.requireActual('~/security_configuration/constants').TEMP_PROVIDER_URLS, // NOTE: Jest hoists all mocks to the top so we can't use TEMP_PROVIDER_LOGOS // here directly. TEMP_PROVIDER_LOGOS: { diff --git a/spec/frontend/security_configuration/mock_data.js b/spec/frontend/security_configuration/mock_data.js index df10d33e2f0..208256afdbd 100644 --- a/spec/frontend/security_configuration/mock_data.js +++ b/spec/frontend/security_configuration/mock_data.js @@ -4,7 +4,7 @@ import { SAST_DESCRIPTION, SAST_HELP_PATH, SAST_CONFIG_HELP_PATH, -} from '~/security_configuration/components/constants'; +} from '~/security_configuration/constants'; import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants'; export const testProjectPath = 'foo/bar'; diff --git a/spec/frontend/security_configuration/utils_spec.js b/spec/frontend/security_configuration/utils_spec.js index ea04e9e7993..3c6d4baa30f 100644 --- a/spec/frontend/security_configuration/utils_spec.js +++ b/spec/frontend/security_configuration/utils_spec.js @@ -1,5 +1,5 @@ import { augmentFeatures, translateScannerNames } from '~/security_configuration/utils'; -import { SCANNER_NAMES_MAP } from '~/security_configuration/components/constants'; +import { SCANNER_NAMES_MAP } from '~/security_configuration/constants'; describe('augmentFeatures', () => { const mockSecurityFeatures = [ diff --git a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js index f3d0d66cdd1..2b36344cfa8 100644 --- a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js +++ b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js @@ -2,7 +2,7 @@ import { GlButton } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants'; +import { featureToMutationMap } from 'ee_else_ce/security_configuration/constants'; import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; diff --git a/spec/graphql/types/permission_types/abuse_report_spec.rb b/spec/graphql/types/permission_types/abuse_report_spec.rb deleted file mode 100644 index 399df137a78..00000000000 --- a/spec/graphql/types/permission_types/abuse_report_spec.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Types::PermissionTypes::AbuseReport, feature_category: :insider_threat do - it do - expected_permissions = [ - :read_abuse_report, :create_note - ] - - expected_permissions.each do |permission| - expect(described_class).to have_graphql_field(permission) - end - end -end diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb index fe8ed2691fb..8aee337f51c 100644 --- a/spec/helpers/groups_helper_spec.rb +++ b/spec/helpers/groups_helper_spec.rb @@ -656,6 +656,41 @@ RSpec.describe GroupsHelper, feature_category: :groups_and_projects do end end + context 'when a user has different access for different groups in the hierarchy' do + let_it_be(:grand_parent) { create(:group) } + let_it_be(:parent) { create(:group, parent: grand_parent) } + let_it_be(:child) { create(:group, parent: parent) } + let_it_be(:grand_child) { create(:group, parent: child) } + + before_all do + parent.add_developer(user) + child.add_maintainer(user) + grand_child.add_owner(user) + end + + it 'returns the access levels that are peers or lower' do + expect(helper.access_level_roles_user_can_assign(grand_parent)).to be_empty + expect(helper.access_level_roles_user_can_assign(parent)).to eq({ + 'Guest' => ::Gitlab::Access::GUEST, + 'Reporter' => ::Gitlab::Access::REPORTER, + 'Developer' => ::Gitlab::Access::DEVELOPER + }) + expect(helper.access_level_roles_user_can_assign(child)).to eq(::Gitlab::Access.options) + expect(helper.access_level_roles_user_can_assign(grand_child)).to eq(::Gitlab::Access.options_with_owner) + end + end + + context 'when a group is linked to another' do + let_it_be(:other_group) { create(:group) } + let_it_be(:group_link) { create(:group_group_link, shared_group: group, shared_with_group: other_group, group_access: Gitlab::Access::MAINTAINER) } + + before_all do + other_group.add_owner(user) + end + + it { is_expected.to eq(::Gitlab::Access.options) } + end + context 'when user is not provided' do before do allow(helper).to receive(:current_user).and_return(nil) diff --git a/spec/policies/abuse_report_policy_spec.rb b/spec/policies/abuse_report_policy_spec.rb index 01ab29d1cf1..3cc92749ab4 100644 --- a/spec/policies/abuse_report_policy_spec.rb +++ b/spec/policies/abuse_report_policy_spec.rb @@ -12,6 +12,7 @@ RSpec.describe AbuseReportPolicy, feature_category: :insider_threat do it 'cannot read_abuse_report' do expect(policy).to be_disallowed(:read_abuse_report) + expect(policy).to be_disallowed(:read_note) expect(policy).to be_disallowed(:create_note) end end @@ -21,6 +22,7 @@ RSpec.describe AbuseReportPolicy, feature_category: :insider_threat do it 'can read_abuse_report' do expect(policy).to be_allowed(:read_abuse_report) + expect(policy).to be_allowed(:read_note) expect(policy).to be_allowed(:create_note) end end diff --git a/spec/requests/api/graphql/abuse_report_spec.rb b/spec/requests/api/graphql/abuse_report_spec.rb index f74b1fb4061..8ab0e92d838 100644 --- a/spec/requests/api/graphql/abuse_report_spec.rb +++ b/spec/requests/api/graphql/abuse_report_spec.rb @@ -25,11 +25,7 @@ RSpec.describe 'Querying an Abuse Report', feature_category: :insider_threat do it 'returns all fields' do expect(abuse_report_data).to include( - 'id' => global_id, - 'userPermissions' => { - 'readAbuseReport' => true, - 'createNote' => true - } + 'id' => global_id ) end end diff --git a/spec/scripts/internal_events/cli_spec.rb b/spec/scripts/internal_events/cli_spec.rb new file mode 100644 index 00000000000..d84a4498fe8 --- /dev/null +++ b/spec/scripts/internal_events/cli_spec.rb @@ -0,0 +1,866 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' +require 'tty/prompt/test' +require_relative '../../../scripts/internal_events/cli' + +RSpec.describe Cli, feature_category: :service_ping do + let(:prompt) { TTY::Prompt::Test.new } + let(:files_to_cleanup) { [] } + + let(:event1_filepath) { 'config/events/internal_events_cli_used.yml' } + let(:event1_content) { internal_event_fixture('events/event_with_identifiers.yml') } + let(:event2_filepath) { 'ee/config/events/internal_events_cli_opened.yml' } + let(:event2_content) { internal_event_fixture('events/ee_event_without_identifiers.yml') } + let(:event3_filepath) { 'config/events/internal_events_cli_closed.yml' } + let(:event3_content) { internal_event_fixture('events/secondary_event_with_identifiers.yml') } + + before do + stub_milestone('16.6') + collect_file_writes(files_to_cleanup) + stub_product_groups(File.read('spec/fixtures/scripts/internal_events/stages.yml')) + stub_helper(:fetch_window_size, '50') + end + + after do + delete_files(files_to_cleanup) + end + + shared_examples 'creates the right defintion files' do |description, test_case = {}| + # For expected keystroke mapping, see https://github.com/piotrmurach/tty-reader/blob/master/lib/tty/reader/keys.rb + let(:keystrokes) { test_case.dig('inputs', 'keystrokes') || [] } + let(:input_files) { test_case.dig('inputs', 'files') || [] } + let(:output_files) { test_case.dig('outputs', 'files') || [] } + + subject { run_with_verbose_timeout } + + it "in scenario: #{description}" do + delete_old_ouputs # just in case + prep_input_files + queue_cli_inputs(keystrokes) + expect_file_creation + + subject + end + + private + + def delete_old_ouputs + [input_files, output_files].flatten.each do |file_info| + FileUtils.rm_f(Rails.root.join(file_info['path'])) + end + end + + def prep_input_files + input_files.each do |file| + File.write( + Rails.root.join(file['path']), + File.read(Rails.root.join(file['content'])) + ) + end + end + + def expect_file_creation + if output_files.any? + output_files.each do |file| + expect(File).to receive(:write).with(file['path'], File.read(file['content'])) + end + else + expect(File).not_to receive(:write) + end + end + end + + context 'when creating new events' do + YAML.safe_load(File.read('spec/fixtures/scripts/internal_events/new_events.yml')).each do |test_case| + it_behaves_like 'creates the right defintion files', test_case['description'], test_case + end + end + + context 'when creating new metrics' do + YAML.safe_load(File.read('spec/fixtures/scripts/internal_events/new_metrics.yml')).each do |test_case| + it_behaves_like 'creates the right defintion files', test_case['description'], test_case + end + + context 'when creating a metric from multiple events' do + let(:events) do + [{ + action: '00_event1', category: 'InternalEventTracking', + product_section: 'dev', product_stage: 'plan', product_group: 'optimize' + }, { + action: '00_event2', category: 'InternalEventTracking', + product_section: 'dev', product_stage: 'create', product_group: 'ide' + }, { + action: '00_event3', category: 'InternalEventTracking', + product_section: 'dev', product_stage: 'create', product_group: 'source_code' + }] + end + + before do + events.each do |event| + File.write("config/events/#{event[:action]}.yml", event.transform_keys(&:to_s).to_yaml) + end + end + + it 'filters the product group options based on common section' do + # Select 00_event1 & #00_event2 + queue_cli_inputs([ + "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time + "2\n", # Enum-select: Multiple events -- count occurrences of several separate events or interactions + " ", # Multi-select: __event1 + "\e[B", # Arrow down to: __event2 + " ", # Multi-select: __event2 + "\n", # Submit selections + "\n", # Select: Weekly/Monthly count of unique users + "aggregate metric description\n", # Submit description + "\n", # Accept description for weekly + "\n" # Copy & continue + ]) + + run_with_timeout + + # Filter down to "dev" options + expect(plain_last_lines(9)).to eq <<~TEXT.chomp + ‣ dev:plan:project_management + dev:plan:product_planning + dev:plan:knowledge + dev:plan:optimize + dev:create:source_code + dev:create:code_review + dev:create:ide + dev:create:editor_extensions + dev:create:code_creation + TEXT + end + + it 'filters the product group options based on common section & stage' do + # Select 00_event2 & #00_event3 + queue_cli_inputs([ + "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time + "2\n", # Enum-select: Multiple events -- count occurrences of several separate events or interactions + "\e[B", # Arrow down to: __event2 + " ", # Multi-select: __event2 + "\e[B", # Arrow down to: __event3 + " ", # Multi-select: __event3 + "\n", # Submit selections + "\n", # Select: Weekly/Monthly count of unique users + "aggregate metric description\n", # Submit description + "\n", # Accept description for weekly + "\n" # Copy & continue + ]) + + run_with_timeout + + # Filter down to "dev:create" options + expect(plain_last_lines(5)).to eq <<~TEXT.chomp + ‣ dev:create:source_code + dev:create:code_review + dev:create:ide + dev:create:editor_extensions + dev:create:code_creation + TEXT + end + end + + context 'when product group for event no longer exists' do + let(:event) do + { + action: '00_event1', category: 'InternalEventTracking', + product_section: 'other', product_stage: 'other', product_group: 'other' + } + end + + before do + File.write("config/events/#{event[:action]}.yml", event.transform_keys(&:to_s).to_yaml) + end + + it 'prompts user to select another group' do + queue_cli_inputs([ + "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time + "1\n", # Enum-select: Single event -- count occurrences of a specific event or user interaction + "\n", # Select: 00__event1 + "\n", # Select: Weekly/Monthly count of unique users + "aggregate metric description\n", # Submit description + "\n", # Accept description for weekly + "2\n" # Modify attributes + ]) + + run_with_timeout + + # Filter down to "dev" options + expect(plain_last_lines(50)).to include 'Select one: Which group owns the metric?' + end + end + + context 'when creating a metric for an event which has metrics' do + before do + File.write(event1_filepath, File.read(event1_content)) + end + + it 'shows all metrics options' do + select_event_from_list + + expect(plain_last_lines(5)).to eq <<~TEXT.chomp + ‣ Monthly/Weekly count of unique users [who triggered internal_events_cli_used] + Monthly/Weekly count of unique projects [where internal_events_cli_used occurred] + Monthly/Weekly count of unique namespaces [where internal_events_cli_used occurred] + Monthly/Weekly count of [internal_events_cli_used occurrences] + Total count of [internal_events_cli_used occurrences] + TEXT + end + + context 'with an existing weekly metric' do + before do + File.write( + 'ee/config/metrics/counts_7d/count_total_internal_events_cli_used_weekly.yml', + File.read('spec/fixtures/scripts/internal_events/metrics/ee_total_7d_single_event.yml') + ) + end + + it 'partially filters metric options' do + select_event_from_list + + expect(plain_last_lines(6)).to eq <<~TEXT.chomp + ‣ Monthly/Weekly count of unique users [who triggered internal_events_cli_used] + Monthly/Weekly count of unique projects [where internal_events_cli_used occurred] + Monthly/Weekly count of unique namespaces [where internal_events_cli_used occurred] + Monthly count of [internal_events_cli_used occurrences] + ✘ Weekly count of [internal_events_cli_used occurrences] (already defined) + Total count of [internal_events_cli_used occurrences] + TEXT + end + end + + context 'with an existing total metric' do + before do + File.write( + 'ee/config/metrics/counts_all/count_total_internal_events_cli_used.yml', + File.read('spec/fixtures/scripts/internal_events/metrics/ee_total_single_event.yml') + ) + end + + it 'filters whole metric options' do + select_event_from_list + + expect(plain_last_lines(5)).to eq <<~TEXT.chomp + ‣ Monthly/Weekly count of unique users [who triggered internal_events_cli_used] + Monthly/Weekly count of unique projects [where internal_events_cli_used occurred] + Monthly/Weekly count of unique namespaces [where internal_events_cli_used occurred] + Monthly/Weekly count of [internal_events_cli_used occurrences] + ✘ Total count of [internal_events_cli_used occurrences] (already defined) + TEXT + end + end + + private + + def select_event_from_list + queue_cli_inputs([ + "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time + "1\n", # Enum-select: Single event -- count occurrences of a specific event or user interaction + 'internal_events_cli_used', # Filters to this event + "\n" # Select: config/events/internal_events_cli_used.yml + ]) + + run_with_timeout + end + end + + context 'when event excludes identifiers' do + before do + File.write(event2_filepath, File.read(event2_content)) + end + + it 'filters unavailable identifiers' do + queue_cli_inputs([ + "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time + "1\n", # Enum-select: Single event -- count occurrences of a specific event or user interaction + 'internal_events_cli_opened', # Filters to this event + "\n" # Select: config/events/internal_events_cli_opened.yml + ]) + + run_with_timeout + + expect(plain_last_lines(5)).to eq <<~TEXT.chomp + ✘ Monthly/Weekly count of unique users [who triggered internal_events_cli_opened] (user unavailable) + ✘ Monthly/Weekly count of unique projects [where internal_events_cli_opened occurred] (project unavailable) + ✘ Monthly/Weekly count of unique namespaces [where internal_events_cli_opened occurred] (namespace unavailable) + ‣ Monthly/Weekly count of [internal_events_cli_opened occurrences] + Total count of [internal_events_cli_opened occurrences] + TEXT + end + end + + context 'when all metrics already exist' do + let(:event) { { action: '00_event1', category: 'InternalEventTracking' } } + let(:metric) { { options: { 'events' => ['00_event1'] }, events: [{ 'name' => '00_event1' }] } } + + let(:files) do + [ + ['config/events/00_event1.yml', event], + ['config/metrics/counts_all/count_total_00_event1.yml', metric.merge(time_frame: 'all')], + ['config/metrics/counts_7d/count_total_00_event1_weekly.yml', metric.merge(time_frame: '7d')], + ['config/metrics/counts_28d/count_total_00_event1_monthly.yml', metric.merge(time_frame: '28d')] + ] + end + + before do + files.each do |path, content| + File.write(path, content.transform_keys(&:to_s).to_yaml) + end + end + + it 'exits the script and directs user to search for existing metrics' do + queue_cli_inputs([ + "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time + "1\n", # Enum-select: Single event -- count occurrences of a specific event or user interaction + '00_event1', # Filters to this event + "\n" # Select: config/events/00_event1.yml + ]) + + run_with_timeout + + expect(plain_last_lines(15)).to include 'Looks like the potential metrics for this event ' \ + 'either already exist or are unsupported.' + end + end + end + + context 'when showing usage examples' do + let(:expected_example_prompt) do + <<~TEXT.chomp + Select one: Select a use-case to view examples for: (Press ↑/↓ arrow or 1-8 number to move and Enter to select) + ‣ 1. ruby/rails + 2. rspec + 3. javascript (vue) + 4. javascript (plain) + 5. vue template + 6. haml + 7. View examples for a different event + 8. Exit + TEXT + end + + context 'for an event with identifiers' do + let(:expected_rails_example) do + <<~TEXT.chomp + -------------------------------------------------- + # RAILS + + Gitlab::InternalEvents.track_event( + 'internal_events_cli_used', + project: project, + namespace: project.namespace, + user: user + ) + + -------------------------------------------------- + TEXT + end + + let(:expected_rspec_example) do + <<~TEXT.chomp + -------------------------------------------------- + # RSPEC + + it_behaves_like 'internal event tracking' do + let(:event) { 'internal_events_cli_used' } + let(:project) { project } + let(:namespace) { project.namespace } + let(:user) { user } + end + + -------------------------------------------------- + TEXT + end + + before do + File.write(event1_filepath, File.read(event1_content)) + end + + it 'shows backend examples' do + queue_cli_inputs([ + "3\n", # Enum-select: View Usage -- look at code examples for an existing event + 'internal_events_cli_used', # Filters to this event + "\n", # Select: config/events/internal_events_cli_used.yml + "\n", # Select: ruby/rails + "\e[B", # Arrow down to: rspec + "\n", # Select: rspec + "8\n" # Exit + ]) + + run_with_timeout + + output = plain_last_lines(100) + + expect(output).to include expected_example_prompt + expect(output).to include expected_rails_example + expect(output).to include expected_rspec_example + end + end + + context 'for an event without identifiers' do + let(:expected_rails_example) do + <<~TEXT.chomp + -------------------------------------------------- + # RAILS + + Gitlab::InternalEvents.track_event('internal_events_cli_opened') + + -------------------------------------------------- + TEXT + end + + let(:expected_rspec_example) do + <<~TEXT.chomp + -------------------------------------------------- + # RSPEC + + it_behaves_like 'internal event tracking' do + let(:event) { 'internal_events_cli_opened' } + end + + -------------------------------------------------- + TEXT + end + + let(:expected_vue_example) do + <<~TEXT.chomp + -------------------------------------------------- + // VUE + + <script> + import { InternalEvents } from '~/tracking'; + import { GlButton } from '@gitlab/ui'; + + const trackingMixin = InternalEvents.mixin(); + + export default { + mixins: [trackingMixin], + components: { GlButton }, + methods: { + performAction() { + this.trackEvent('internal_events_cli_opened'); + }, + }, + }; + </script> + + <template> + <gl-button @click=performAction>Click Me</gl-button> + </template> + + -------------------------------------------------- + TEXT + end + + let(:expected_js_example) do + <<~TEXT.chomp + -------------------------------------------------- + // FRONTEND -- RAW JAVASCRIPT + + import { InternalEvents } from '~/tracking'; + + export const performAction = () => { + InternalEvents.trackEvent('internal_events_cli_opened'); + + return true; + }; + + -------------------------------------------------- + TEXT + end + + let(:expected_vue_template_example) do + <<~TEXT.chomp + -------------------------------------------------- + // VUE TEMPLATE -- ON-CLICK + + <script> + import { GlButton } from '@gitlab/ui'; + + export default { + components: { GlButton } + }; + </script> + + <template> + <gl-button data-event-tracking="internal_events_cli_opened"> + Click Me + </gl-button> + </template> + + -------------------------------------------------- + // VUE TEMPLATE -- ON-LOAD + + <script> + import { GlButton } from '@gitlab/ui'; + + export default { + components: { GlButton } + }; + </script> + + <template> + <gl-button data-event-tracking-load="internal_events_cli_opened"> + Click Me + </gl-button> + </template> + + -------------------------------------------------- + TEXT + end + + let(:expected_haml_example) do + <<~TEXT.chomp + -------------------------------------------------- + # HAML -- ON-CLICK + + .gl-display-inline-block{ data: { event_tracking: 'internal_events_cli_opened' } } + = _('Important Text') + + -------------------------------------------------- + # HAML -- COMPONENT ON-CLICK + + = render Pajamas::ButtonComponent.new(button_options: { data: { event_tracking: 'internal_events_cli_opened' } }) + + -------------------------------------------------- + # HAML -- COMPONENT ON-LOAD + + = render Pajamas::ButtonComponent.new(button_options: { data: { event_tracking_load: true, event_tracking: 'internal_events_cli_opened' } }) + + -------------------------------------------------- + TEXT + end + + before do + File.write(event2_filepath, File.read(event2_content)) + end + + it 'shows all examples' do + queue_cli_inputs([ + "3\n", # Enum-select: View Usage -- look at code examples for an existing event + 'internal_events_cli_opened', # Filters to this event + "\n", # Select: config/events/internal_events_cli_used.yml + "\n", # Select: ruby/rails + "\e[B", # Arrow down to: rspec + "\n", # Select: rspec + "\e[B", # Arrow down to: js vue + "\n", # Select: js vue + "\e[B", # Arrow down to: js plain + "\n", # Select: js plain + "\e[B", # Arrow down to: vue template + "\n", # Select: vue template + "\e[B", # Arrow down to: haml + "\n", # Select: haml + "8\n" # Exit + ]) + + run_with_timeout + + output = plain_last_lines(1000) + + expect(output).to include expected_example_prompt + expect(output).to include expected_rails_example + expect(output).to include expected_rspec_example + expect(output).to include expected_vue_example + expect(output).to include expected_js_example + expect(output).to include expected_vue_template_example + expect(output).to include expected_haml_example + end + end + + context 'when viewing examples for multiple events' do + let(:expected_event1_example) do + <<~TEXT.chomp + -------------------------------------------------- + # RAILS + + Gitlab::InternalEvents.track_event( + 'internal_events_cli_used', + project: project, + namespace: project.namespace, + user: user + ) + + -------------------------------------------------- + TEXT + end + + let(:expected_event2_example) do + <<~TEXT.chomp + -------------------------------------------------- + # RAILS + + Gitlab::InternalEvents.track_event('internal_events_cli_opened') + + -------------------------------------------------- + TEXT + end + + before do + File.write(event1_filepath, File.read(event1_content)) + File.write(event2_filepath, File.read(event2_content)) + end + + it 'switches between events gracefully' do + queue_cli_inputs([ + "3\n", # Enum-select: View Usage -- look at code examples for an existing event + 'internal_events_cli_used', # Filters to this event + "\n", # Select: config/events/internal_events_cli_used.yml + "\n", # Select: ruby/rails + "7\n", # Select: View examples for a different event + 'internal_events_cli_opened', # Filters to this event + "\n", # Select: config/events/internal_events_cli_opened.yml + "\n", # Select: ruby/rails + "8\n" # Exit + ]) + + run_with_timeout + + output = plain_last_lines(300) + + expect(output).to include expected_example_prompt + expect(output).to include expected_event1_example + expect(output).to include expected_event2_example + end + end + end + + context 'when offline' do + before do + stub_product_groups(nil) + end + + it_behaves_like 'creates the right defintion files', + 'Creates a new event with product stage/section/group input manually' do + let(:keystrokes) do + [ + "1\n", # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + "Internal Event CLI is opened\n", # Submit description + "internal_events_cli_opened\n", # Submit action name + "6\n", # Select: None + "\n", # Skip MR URL + "analytics\n", # Input section + "monitor\n", # Input stage + "analytics_instrumentation\n", # Input group + "2\n", # Select [premium, ultimate] + "y\n", # Create file + "3\n" # Exit + ] + end + + let(:output_files) { [{ 'path' => event2_filepath, 'content' => event2_content }] } + end + + it_behaves_like 'creates the right defintion files', + 'Creates a new metric with product stage/section/group input manually' do + let(:keystrokes) do + [ + "2\n", # Enum-select: New Metric -- calculate how often one or more existing events occur over time + "2\n", # Enum-select: Multiple events -- count occurrences of several separate events or interactions + 'internal_events_cli', # Filters to the relevant events + ' ', # Multi-select: internal_events_cli_closed + "\e[B", # Arrow down to: internal_events_cli_used + ' ', # Multi-select: internal_events_cli_used + "\n", # Submit selections + "\e[B", # Arrow down to: Weekly count of unique projects + "\n", # Select: Weekly count of unique projects + "where a defition file was created with the CLI\n", # Input description + "\n", # Submit weekly description for monthly + "2\n", # Select: Modify attributes + "\n", # Accept section + "\n", # Accept stage + "\n", # Accept group + "\n", # Skip URL + "1\n", # Select: [free, premium, ultimate] + "y\n", # Create file + "y\n", # Create file + "2\n" # Exit + ] + end + + let(:input_files) do + [ + { 'path' => event1_filepath, 'content' => event1_content }, + { 'path' => event3_filepath, 'content' => event3_content } + ] + end + + let(:output_files) do + # rubocop:disable Layout/LineLength -- Long filepaths read better unbroken + [{ + 'path' => 'config/metrics/counts_28d/count_distinct_project_id_from_internal_events_cli_closed_and_internal_events_cli_used_monthly.yml', + 'content' => 'spec/fixtures/scripts/internal_events/metrics/project_id_28d_multiple_events.yml' + }, { + 'path' => 'config/metrics/counts_7d/count_distinct_project_id_from_internal_events_cli_closed_and_internal_events_cli_used_weekly.yml', + 'content' => 'spec/fixtures/scripts/internal_events/metrics/project_id_7d_multiple_events.yml' + }] + # rubocop:enable Layout/LineLength + end + end + end + + context 'when window size is unavailable' do + before do + # `tput <cmd>` returns empty string on error + stub_helper(:fetch_window_size, '') + stub_helper(:fetch_window_height, '') + end + + it_behaves_like 'creates the right defintion files', + 'Terminal size does not prevent file creation' do + let(:keystrokes) do + [ + "1\n", # Enum-select: New Event -- start tracking when an action or scenario occurs on gitlab instances + "Internal Event CLI is opened\n", # Submit description + "internal_events_cli_opened\n", # Submit action name + "6\n", # Select: None + "\n", # Skip MR URL + "instrumentation\n", # Filter & select group + "2\n", # Select [premium, ultimate] + "y\n", # Create file + "3\n" # Exit + ] + end + + let(:output_files) { [{ 'path' => event2_filepath, 'content' => event2_content }] } + end + end + + context "when user doesn't know what they're trying to do" do + it "handles when user isn't trying to track product usage" do + queue_cli_inputs([ + "4\n", # Enum-select: ...am I in the right place? + "n\n" # No --> Are you trying to track customer usage of a GitLab feature? + ]) + + run_with_timeout + + expect(plain_last_lines(50)).to include("Oh no! This probably isn't the tool you need!") + end + + it "handles when product usage can't be tracked with events" do + queue_cli_inputs([ + "4\n", # Enum-select: ...am I in the right place? + "y\n", # Yes --> Are you trying to track customer usage of a GitLab feature? + "n\n" # No --> Can usage for the feature be measured by tracking a specific user action? + ]) + + run_with_timeout + + expect(plain_last_lines(50)).to include("Oh no! This probably isn't the tool you need!") + end + + it 'handles when user needs to add a new event' do + queue_cli_inputs([ + "4\n", # Enum-select: ...am I in the right place? + "y\n", # Yes --> Are you trying to track customer usage of a GitLab feature? + "y\n", # Yes --> Can usage for the feature be measured by tracking a specific user action? + "n\n", # No --> Is the event already tracked? + "n\n" # No --> Ready to start? + ]) + + run_with_timeout + + expect(plain_last_lines(30)).to include("Okay! The next step is adding a new event! (~5 min)") + end + + it 'handles when user needs to add a new metric' do + queue_cli_inputs([ + "4\n", # Enum-select: ...am I in the right place? + "y\n", # Yes --> Are you trying to track customer usage of a GitLab feature? + "y\n", # Yes --> Can usage for the feature be measured by tracking a specific user action? + "y\n", # Yes --> Is the event already tracked? + "n\n" # No --> Ready to start? + ]) + + run_with_timeout + + expect(plain_last_lines(30)).to include("Amazing! The next step is adding a new metric! (~8 min)") + end + end + + private + + def queue_cli_inputs(keystrokes) + prompt.input << keystrokes.join('') + prompt.input.rewind + end + + def run_with_timeout(duration = 1) + Timeout.timeout(duration) { described_class.new(prompt).run } + rescue Timeout::Error + # Timeout is needed to break out of the CLI, but we may want + # to make assertions afterwards + end + + def run_with_verbose_timeout(duration = 1) + Timeout.timeout(duration) { described_class.new(prompt).run } + rescue Timeout::Error => e + # Re-raise error so CLI output is printed with the error + message = <<~TEXT + Awaiting input too long. Entire CLI output: + + #{ + prompt.output.string.lines + .map { |line| "\e[0;37m#{line}\e[0m" } # wrap in white + .join('') + .gsub("\e[1G", "\e[1G ") # align to error indent + } + + + TEXT + + raise e.class, message, e.backtrace + end + + def plain_last_lines(size) + prompt.output.string + .lines + .last(size) + .join('') + .gsub(/\e[^\sm]{2,4}[mh]/, '') + end + + def collect_file_writes(collector) + allow(File).to receive(:write).and_wrap_original do |original_method, *args, &block| + filepath = args.first + collector << filepath + + dirname = Pathname.new(filepath).dirname + unless dirname.directory? + FileUtils.mkdir_p dirname + collector << dirname.to_s + end + + original_method.call(*args, &block) + end + end + + def stub_milestone(milestone) + stub_const("InternalEventsCli::Helpers::MILESTONE", milestone) + end + + def stub_product_groups(body) + allow(Net::HTTP).to receive(:get) + .with(URI('https://gitlab.com/gitlab-com/www-gitlab-com/-/raw/master/data/stages.yml')) + .and_return(body) + end + + def stub_helper(helper, value) + # rubocop:disable RSpec/AnyInstanceOf -- 'Next' helper not included in fast_spec_helper & next is insufficient + allow_any_instance_of(InternalEventsCli::Helpers).to receive(helper).and_return(value) + # rubocop:enable RSpec/AnyInstanceOf + end + + def delete_files(files) + files.each do |filepath| + FileUtils.rm_f(Rails.root.join(filepath)) + end + end + + def internal_event_fixture(filepath) + Rails.root.join('spec', 'fixtures', 'scripts', 'internal_events', filepath) + end +end diff --git a/spec/serializers/merge_request_poll_widget_entity_spec.rb b/spec/serializers/merge_request_poll_widget_entity_spec.rb index 6b80609c348..171f2324cf1 100644 --- a/spec/serializers/merge_request_poll_widget_entity_spec.rb +++ b/spec/serializers/merge_request_poll_widget_entity_spec.rb @@ -22,6 +22,9 @@ RSpec.describe MergeRequestPollWidgetEntity do .to eq(resource.default_merge_commit_message(include_description: true)) end + it { is_expected.to include(ff_only_enabled: false) } + it { is_expected.to include(ff_merge_possible: false) } + describe 'new_blob_path' do context 'when user can push to project' do it 'returns path' do |