Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-07-22 18:21:50 +00:00
parent c0fe5a7e5b
commit fb663cc988
120 changed files with 2190 additions and 1112 deletions

View File

@ -126,6 +126,11 @@ rules:
message: 'No hard coded url, use `PROMO_URL` in `jh_else_ce/lib/utils/url_utility`'
- selector: TemplateLiteral[expressions.0.name=DOCS_URL] > TemplateElement[value.cooked=/\u002Fjh|\u002Fee/]
message: '`/ee` or `/jh` path found in docs url, use `DOCS_URL_IN_EE_DIR` in `jh_else_ce/lib/utils/url_utility`'
no-restricted-properties:
- error
- object: window
property: open
message: 'Use `visitUrl` in `jh_else_ce/lib/utils/url_utility` to avoid cross-site leaks.'
no-restricted-imports:
- error
- paths:
@ -191,6 +196,7 @@ overrides:
message: 'No hard coded url, use `PROMO_URL` in `jh_else_ce/lib/utils/url_utility`'
- selector: TemplateLiteral[expressions.0.name=DOCS_URL] > TemplateElement[value.cooked=/\u002Fjh|\u002Fee/]
message: '`/ee` or `/jh` path found in docs url, use `DOCS_URL_IN_EE_DIR` in `jh_else_ce/lib/utils/url_utility`'
no-restricted-properties: off
no-unsanitized/method: off
no-unsanitized/property: off
local-rules/require-valid-help-page-path: off

View File

@ -219,7 +219,7 @@ variables:
DOCS_REVIEW_APPS_DOMAIN: "docs.gitlab-review.app"
DOCS_GITLAB_REPO_SUFFIX: "ee"
REVIEW_APPS_IMAGE: "${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/${BUILD_OS}-${OS_VERSION}-ruby-${RUBY_VERSION}:gcloud-383-kubectl-1.27-helm-3.9"
REVIEW_APPS_IMAGE: "${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images/${BUILD_OS}-${OS_VERSION}-ruby-${RUBY_VERSION}:gcloud-383-kubectl-1.28-helm-3.9"
REVIEW_APPS_DOMAIN: "gitlab-review.app"
REVIEW_APPS_GCP_PROJECT: "gitlab-review-apps"
REVIEW_APPS_GCP_REGION: "us-central1"

View File

@ -369,7 +369,6 @@ RSpec/ContextWording:
- 'ee/spec/lib/gitlab/insights/finders/issuable_finder_spec.rb'
- 'ee/spec/lib/gitlab/insights/project_insights_config_spec.rb'
- 'ee/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb'
- 'ee/spec/lib/gitlab/search/aggregation_parser_spec.rb'
- 'ee/spec/lib/gitlab/sitemaps/url_extractor_spec.rb'
- 'ee/spec/lib/gitlab/slash_commands/presenters/issue_show_spec.rb'
- 'ee/spec/lib/gitlab/status_page/filter/image_filter_spec.rb'

View File

@ -678,7 +678,6 @@ RSpec/FeatureCategory:
- 'ee/spec/lib/gitlab/reference_extractor_spec.rb'
- 'ee/spec/lib/gitlab/regex_spec.rb'
- 'ee/spec/lib/gitlab/return_to_location_spec.rb'
- 'ee/spec/lib/gitlab/search/aggregation_parser_spec.rb'
- 'ee/spec/lib/gitlab/search/aggregation_spec.rb'
- 'ee/spec/lib/gitlab/search/client_spec.rb'
- 'ee/spec/lib/gitlab/search/recent_epics_spec.rb'

View File

@ -457,7 +457,6 @@ RSpec/NamedSubject:
- 'ee/spec/lib/gitlab/patch/draw_route_spec.rb'
- 'ee/spec/lib/gitlab/proxy_spec.rb'
- 'ee/spec/lib/gitlab/reference_extractor_spec.rb'
- 'ee/spec/lib/gitlab/search/aggregation_parser_spec.rb'
- 'ee/spec/lib/gitlab/search/aggregation_spec.rb'
- 'ee/spec/lib/gitlab/search/client_spec.rb'
- 'ee/spec/lib/gitlab/search_context/builder_spec.rb'

23
Gemfile
View File

@ -59,7 +59,8 @@ gem 'neighbor', '~> 0.3.2', feature_category: :duo_chat
gem 'rugged', '~> 1.6' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'faraday', '~> 1.10.3' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'faraday', '~> 2', feature_category: :shared
gem 'faraday-retry', '~> 2', feature_category: :shared
gem 'marginalia', '~> 1.11.1' # rubocop:todo Gemfile/MissingFeatureCategory
# Authorization
@ -116,7 +117,7 @@ gem 'attr_encrypted', '~> 3.2.4', path: 'vendor/gems/attr_encrypted' # rubocop:t
gem 'validates_hostname', '~> 1.0.13' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'rubyzip', '~> 2.3.2', require: 'zip' # rubocop:todo Gemfile/MissingFeatureCategory
# GitLab Pages letsencrypt support
gem 'acme-client', '~> 2.0' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'acme-client', '~> 2.0.18' # rubocop:todo Gemfile/MissingFeatureCategory
# Browser detection
gem 'browser', '~> 5.3.1' # rubocop:todo Gemfile/MissingFeatureCategory
@ -146,7 +147,7 @@ gem 'graphql', '~> 2.3.5', feature_category: :api
gem 'graphql-docs', '~> 5.0.0', group: [:development, :test], feature_category: :api
gem 'graphiql-rails', '~> 1.10', feature_category: :api
gem 'apollo_upload_server', '~> 2.1.6', feature_category: :api
gem 'graphlient', '~> 0.6.0', feature_category: :importers # Used by BulkImport feature (group::import)
gem 'graphlient', '~> 0.8.0', feature_category: :importers # Used by BulkImport feature (group::import)
# Generate Fake data
gem 'ffaker', '~> 2.23' # rubocop:todo Gemfile/MissingFeatureCategory
@ -177,7 +178,7 @@ gem 'fog-local', '~> 0.8' # rubocop:todo Gemfile/MissingFeatureCategory
# We may want to update this dependency if this is ever addressed upstream, e.g. via
# https://github.com/aliyun/aliyun-oss-ruby-sdk/pull/93
gem 'fog-aliyun', '~> 0.4' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'gitlab-fog-azure-rm', '~> 1.9.1', require: 'fog/azurerm' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'gitlab-fog-azure-rm', '~> 2.0.1', require: 'fog/azurerm', feature_category: :shared
# for Google storage
@ -205,14 +206,16 @@ gem 'google-cloud-compute-v1', '~> 2.6.0', feature_category: :shared
gem 'seed-fu', '~> 2.3.7' # rubocop:todo Gemfile/MissingFeatureCategory
# Search
gem 'elasticsearch-model', '~> 7.2' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'elasticsearch-rails', '~> 7.2', require: 'elasticsearch/rails/instrumentation' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'elasticsearch-api', '7.13.3' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'elasticsearch-model', '~> 7.2', feature_category: :global_search
gem 'elasticsearch-rails', '~> 7.2', require: 'elasticsearch/rails/instrumentation', feature_category: :global_search
gem 'elasticsearch-api', '7.17.11', feature_category: :global_search
gem 'aws-sdk-core', '~> 3.200.0' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'aws-sdk-cloudformation', '~> 1' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'aws-sdk-s3', '~> 1.155.0' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'faraday_middleware-aws-sigv4', '~>0.3.0' # rubocop:todo Gemfile/MissingFeatureCategory
gem 'typhoeus', '~> 1.4.0' # Used with Elasticsearch to support http keep-alive connections # rubocop:todo Gemfile/MissingFeatureCategory
gem 'faraday-typhoeus', '~> 1.1', feature_category: :global_search
gem 'faraday_middleware-aws-sigv4', '~> 1.0.1', feature_category: :global_search
# Used with Elasticsearch to support http keep-alive connections
gem 'typhoeus', '~> 1.4.0', feature_category: :global_search
# Markdown and HTML processing
gem 'html-pipeline', '~> 2.14.3', feature_category: :team_planning
@ -569,6 +572,8 @@ group :test do
end
gem 'octokit', '~> 9.0', feature_category: :importers
# Needed by octokit: https://github.com/octokit/octokit.rb/pull/1688
gem 'faraday-multipart', '~> 1.0', feature_category: :importers
gem 'gitlab-mail_room', '~> 0.0.24', require: 'mail_room', feature_category: :shared

View File

@ -1,7 +1,7 @@
[
{"name":"CFPropertyList","version":"3.0.5","platform":"ruby","checksum":"a78551cd4768d78ebca98488c27e33652ef818be64697a54676d34e6434674a4"},
{"name":"RedCloth","version":"4.3.3","platform":"ruby","checksum":"d941b8ac96e2730d2d9326d97dda9fcf64cb73532b3f902d91c18970c5f4632d"},
{"name":"acme-client","version":"2.0.11","platform":"ruby","checksum":"edf6da9f3c5dbe3ab0c6738eb3b97978b7a60e3500445480d2a72fcc610089de"},
{"name":"acme-client","version":"2.0.18","platform":"ruby","checksum":"3feab341926ffc16eb65babe51ba4dad8180c13e21e774871344e0b3502ef275"},
{"name":"actioncable","version":"7.0.8.4","platform":"ruby","checksum":"7997fbc32d49c2cffbfe050540a316c96d287b9ef09fa6fe659821373dc186b0"},
{"name":"actionmailbox","version":"7.0.8.4","platform":"ruby","checksum":"3326fd92baabc3ff9d6e0931dc455769e9571604202af49b2d4cb84cf0062074"},
{"name":"actionmailer","version":"7.0.8.4","platform":"ruby","checksum":"f9f3a782b6cc9568fb1e61395f13ca4cf79afaa1fca85a1d314aef63cd3abac1"},
@ -44,8 +44,6 @@
{"name":"axe-core-api","version":"4.9.1","platform":"ruby","checksum":"9ea7ac16bfee1cb3545345d210878aa8cccfb41b493e00fe1faab79af4d9fed8"},
{"name":"axe-core-rspec","version":"4.9.1","platform":"ruby","checksum":"31ef067bee36d6efb3f156a83aa2fb6ac721270a53fb9473f0268e325a3e6efd"},
{"name":"axiom-types","version":"0.1.1","platform":"ruby","checksum":"c1ff113f3de516fa195b2db7e0a9a95fd1b08475a502ff660d04507a09980383"},
{"name":"azure-storage-blob","version":"2.0.3","platform":"ruby","checksum":"61b76118843c91776bd24bee22c74adafeb7c4bb3a858a325047dae3b59d0363"},
{"name":"azure-storage-common","version":"2.0.4","platform":"ruby","checksum":"608f4daab0e06b583b73dcffd3246ea39e78056de31630286b0cf97af7d6956b"},
{"name":"babosa","version":"2.0.0","platform":"ruby","checksum":"a6218db8a4dc8fd99260dde8bc3d5fa1a0c52178196e236ebb31e41fbdcdb8a6"},
{"name":"backport","version":"1.2.0","platform":"ruby","checksum":"912c7dfdd9ee4625d013ddfccb6205c3f92da69a8990f65c440e40f5b2fc7f75"},
{"name":"base32","version":"0.3.2","platform":"ruby","checksum":"532e9b19c5dd1fce281df67fc93a803ebd5d26426a93f6dda6612769bc46fe2c"},
@ -143,11 +141,11 @@
{"name":"ecma-re-validator","version":"0.3.0","platform":"ruby","checksum":"66a95bd8c2b0641baf1fbf9bd355a0dcf13c82c6883f6f496a722420a8b6e0d7"},
{"name":"ed25519","version":"1.3.0","platform":"java","checksum":"8e5d2f8a5325c7a463d61d1a48406ce54074c610f3dccd889e6532c9527a3894"},
{"name":"ed25519","version":"1.3.0","platform":"ruby","checksum":"514a5584f84d39daac568a17ec93a4e7261e140c52c562ed8c382c18456e627d"},
{"name":"elasticsearch","version":"7.13.3","platform":"ruby","checksum":"58b1ad787fafd41836388176dc09e914b2f6e0b257e73b8a51a704ba6bf75b41"},
{"name":"elasticsearch-api","version":"7.13.3","platform":"ruby","checksum":"888f35c64c896db7909f1a56f6c383c45ad6b73c3231649b9c989e39b3d2ba2e"},
{"name":"elasticsearch-model","version":"7.2.0","platform":"ruby","checksum":"2cc1810a45619223c43eff78c6112988f12d475d201523243007dccc6ef96cc7"},
{"name":"elasticsearch","version":"7.17.11","platform":"ruby","checksum":"ed080f085d939f21d07f424ebcea95326e4bdb5f770a8f33aac699374f2ffc86"},
{"name":"elasticsearch-api","version":"7.17.11","platform":"ruby","checksum":"fed8f7b64493c97cf3984a33396a798204b54b8e1b01c5b6c099fa3fd4209107"},
{"name":"elasticsearch-model","version":"7.2.1","platform":"ruby","checksum":"8b5c4b57664bb29f4854fa39603b5ccecfbf9b22fee87bcd16917321dae6a20b"},
{"name":"elasticsearch-rails","version":"7.2.1","platform":"ruby","checksum":"0750dc0e956358d9a3a0912a8186c266ef19f8de0b178c61996ed1a6998156e4"},
{"name":"elasticsearch-transport","version":"7.13.3","platform":"ruby","checksum":"ab8d0226652fb5b32923f172c1abfbc7464058b7de2d9dde3215c88d518c8e2e"},
{"name":"elasticsearch-transport","version":"7.17.11","platform":"ruby","checksum":"d18057d5295e4c39fe80084ede9e00e9c0e0d74580348985f8677b2fb7f70f03"},
{"name":"email_reply_trimmer","version":"0.1.6","platform":"ruby","checksum":"9fede222ce660993e4e2e3dad282535ceb7914e246eb8302c19aa9e021f7326e"},
{"name":"email_spec","version":"2.2.0","platform":"ruby","checksum":"60b7980580a835e7f676db60667f17a2d60e8e0e39c26d81cfc231805c544d79"},
{"name":"encryptor","version":"3.0.0","platform":"ruby","checksum":"abf23f94ab4d864b8cea85b43f3432044a60001982cda7c33c1cd90da8db1969"},
@ -162,20 +160,15 @@
{"name":"extended-markdown-filter","version":"0.7.0","platform":"ruby","checksum":"c8eeef7409fbae18c6b407cd3e4eeb5d25c35cb08fe1ac06f375df3db2d4f138"},
{"name":"factory_bot","version":"6.4.5","platform":"ruby","checksum":"d71dd29bc95f0ec2bf27e3dd9b1b4d557bd534caca744663cb7db4bacf3198be"},
{"name":"factory_bot_rails","version":"6.4.3","platform":"ruby","checksum":"ea73ceac1c0ff3dc11fff390bf2ea8a2604066525ed8ecd3b3bc2c267226dcc8"},
{"name":"faraday","version":"1.10.3","platform":"ruby","checksum":"20f52e9f73231e5f3d43fb645901573ce2b75f0bd01ea52a2772133d0106e6b0"},
{"name":"faraday-em_http","version":"1.0.0","platform":"ruby","checksum":"7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689"},
{"name":"faraday-em_synchrony","version":"1.0.0","platform":"ruby","checksum":"460dad1c30cc692d6e77d4c391ccadb4eca4854b315632cd7e560f74275cf9ed"},
{"name":"faraday-excon","version":"1.1.0","platform":"ruby","checksum":"b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940"},
{"name":"faraday","version":"2.10.0","platform":"ruby","checksum":"1a3e6c02acc511fc334d799521f1013e449bde38aa2dceb3af71e8030519bda9"},
{"name":"faraday-follow_redirects","version":"0.3.0","platform":"ruby","checksum":"d92d975635e2c7fe525dd494fcd4b9bb7f0a4a0ec0d5f4c15c729530fdb807f9"},
{"name":"faraday-http-cache","version":"2.5.0","platform":"ruby","checksum":"64b7366d66e508e1c3dd855ebb20ce9da429330e412a23d9ebbc0a7a7b227463"},
{"name":"faraday-httpclient","version":"1.0.1","platform":"ruby","checksum":"4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b"},
{"name":"faraday-multipart","version":"1.0.4","platform":"ruby","checksum":"9012021ab57790f7d712f590b48d5f948b19b43cfa11ca83e6459f06090b0725"},
{"name":"faraday-net_http","version":"1.0.1","platform":"ruby","checksum":"3245ce406ebb77b40e17a77bfa66191dda04be2fd4e13a78d8a4305854d328ba"},
{"name":"faraday-net_http_persistent","version":"1.2.0","platform":"ruby","checksum":"0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335"},
{"name":"faraday-patron","version":"1.0.0","platform":"ruby","checksum":"dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7"},
{"name":"faraday-rack","version":"1.0.0","platform":"ruby","checksum":"ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0"},
{"name":"faraday-retry","version":"1.0.3","platform":"ruby","checksum":"add154f4f399243cbe070806ed41b96906942e7f5259bb1fe6daf2ec8f497194"},
{"name":"faraday_middleware","version":"1.2.0","platform":"ruby","checksum":"ded15d574d50e92bd04448d5566913af5cb1a01b2fa311ceecc2464fa0ab88af"},
{"name":"faraday_middleware-aws-sigv4","version":"0.3.0","platform":"ruby","checksum":"744654bd5b15539a54aed39b806e2dfb45aa47708fa1e6f6766fedcda6c262be"},
{"name":"faraday-net_http","version":"3.1.0","platform":"ruby","checksum":"1627be414960d0131691190ff524506ba6607402a50fb6eccda9e64ca60f859f"},
{"name":"faraday-net_http_persistent","version":"2.1.0","platform":"ruby","checksum":"b41720b13f56dae77114d9de54baef2d76d0b06ab40d695b2a98e254b56ade0b"},
{"name":"faraday-retry","version":"2.2.1","platform":"ruby","checksum":"4146fed14549c0580bf14591fca419a40717de0dd24f267a8ec2d9a728677608"},
{"name":"faraday-typhoeus","version":"1.1.0","platform":"ruby","checksum":"24c6147c213818dde3ebc50ae47ab92f9a7e554903aa362707126f749c6890e7"},
{"name":"faraday_middleware-aws-sigv4","version":"1.0.1","platform":"ruby","checksum":"a001ea4f687ca1c60bad8f2a627196905ce3dbf285e461dc153240e92eaabe8f"},
{"name":"fast_blank","version":"1.0.1","platform":"java","checksum":"90d82106b0e4aa19ac24ba1604c79a0c5a4c471601e800c9b2b072938a6d9a92"},
{"name":"fast_blank","version":"1.0.1","platform":"ruby","checksum":"269fc30414fed4e6403bc4a49081e1ea539f8b9226e59276ed1efaefabaa17ea"},
{"name":"fast_gettext","version":"2.3.0","platform":"ruby","checksum":"0253e26423ccab68061c42387827e3b99243a1b15ad614df1c800ba870d64f84"},
@ -219,7 +212,7 @@
{"name":"gitlab-chronic","version":"0.10.5","platform":"ruby","checksum":"f80f18dc699b708870a80685243331290bc10cfeedb6b99c92219722f729c875"},
{"name":"gitlab-dangerfiles","version":"4.8.0","platform":"ruby","checksum":"b327d079552ec974a63bf34d749a0308425af6ebf51d01064f1a6ff216a523db"},
{"name":"gitlab-experiment","version":"0.9.1","platform":"ruby","checksum":"f230ee742154805a755d5f2539dc44d93cdff08c5bbbb7656018d61f93d01f48"},
{"name":"gitlab-fog-azure-rm","version":"1.9.1","platform":"ruby","checksum":"026b8e188ac4183c1bf1b1909b0489da0ffad453996a6e744e0eba67dc284f37"},
{"name":"gitlab-fog-azure-rm","version":"2.0.1","platform":"ruby","checksum":"61cc049fa98cc61bc735c14ea01c1e4179e8439ad84ba496c8b5939810aa7925"},
{"name":"gitlab-glfm-markdown","version":"0.0.17","platform":"aarch64-linux","checksum":"81ccfd91c7a1da4b165e700f1a6fbb15cf20ffd283ec8c6e05d5e2078a569717"},
{"name":"gitlab-glfm-markdown","version":"0.0.17","platform":"arm64-darwin","checksum":"2f9da51bb0e57ca431fe957e384c385c4380127a9a22ff3cbf7e0c67efb35897"},
{"name":"gitlab-glfm-markdown","version":"0.0.17","platform":"ruby","checksum":"f379545fc53a71c31525025fdb422f46081133af5cced3130ce680b155c2aa69"},
@ -282,7 +275,7 @@
{"name":"grape-swagger-entity","version":"0.5.4","platform":"ruby","checksum":"34c1644de6523c64cee922988bad3d1057634224f26dd48b9b5c1f90709bb571"},
{"name":"grape_logging","version":"1.8.4","platform":"ruby","checksum":"efcc3e322dbd5d620a68f078733b7db043cf12680144cd03c982f14115c792d1"},
{"name":"graphiql-rails","version":"1.10.0","platform":"ruby","checksum":"b557f989a737c8b9e985142609bec52fb1e9393a701eb50e02a7c14422891040"},
{"name":"graphlient","version":"0.6.0","platform":"ruby","checksum":"b8d8664b4c8ec215012cbe3cca918a045b0a206d709712d68b6db51fd215c5c0"},
{"name":"graphlient","version":"0.8.0","platform":"ruby","checksum":"98c408da1d083454e9f5e274f3b0b6261e2a0c2b5f2ed7b3ef9441d46f8e7cb1"},
{"name":"graphlyte","version":"1.0.0","platform":"ruby","checksum":"b5af4ab67dde6e961f00ea1c18f159f73b52ed11395bb4ece297fe628fa1804d"},
{"name":"graphql","version":"2.3.5","platform":"ruby","checksum":"9c367835f86541660d24c3d81632267ecee553d304577aaee070f8ac05860af1"},
{"name":"graphql-client","version":"0.23.0","platform":"ruby","checksum":"f238b8e451676baad06bd15f95396e018192243dcf12c4e6d13fb41d9a2babc1"},
@ -607,7 +600,7 @@
{"name":"rubocop-rspec","version":"2.27.1","platform":"ruby","checksum":"2f27ce04700be75db65afe83d7993a36e0fafd07ec062222f4b3cc10137a7a9e"},
{"name":"ruby-fogbugz","version":"0.3.0","platform":"ruby","checksum":"5e04cde474648f498a71cf1e1a7ab42c66b953862fbe224f793ec0a7a1d5f657"},
{"name":"ruby-lsp","version":"0.17.4","platform":"ruby","checksum":"49ea1d6a49f5cfb79296fcb96c1988768737c2325270f6dce5aec64a1840e213"},
{"name":"ruby-lsp-rails","version":"0.3.7","platform":"ruby","checksum":"392c831644a72a058f02d15ec48ff80e7e6dde14049d70d791ce798b3cdc9bf0"},
{"name":"ruby-lsp-rails","version":"0.3.8","platform":"ruby","checksum":"847d3ac0a131a794831ceb3ad780ab3e00ddb4e91ab8e965017c817a6068a975"},
{"name":"ruby-lsp-rspec","version":"0.1.12","platform":"ruby","checksum":"34fe775e27dc4c2f31df901f3d44ee885ed0806b05ba9be0ea564682dd4811e5"},
{"name":"ruby-magic","version":"0.6.0","platform":"ruby","checksum":"7b2138877b7d23aff812c95564eba6473b74b815ef85beb0eb792e729a2b6101"},
{"name":"ruby-openai","version":"3.7.0","platform":"ruby","checksum":"fb735d4c055e282ade264cab9864944c05a8a10e0cddd45a0551e8a9851b1850"},
@ -653,7 +646,7 @@
{"name":"sawyer","version":"0.9.2","platform":"ruby","checksum":"fa3a72d62a4525517b18857ddb78926aab3424de0129be6772a8e2ba240e7aca"},
{"name":"sd_notify","version":"0.1.1","platform":"ruby","checksum":"cbc7ac6caa7cedd26b30a72b5eeb6f36050dc0752df263452ea24fb5a4ad3131"},
{"name":"seed-fu","version":"2.3.7","platform":"ruby","checksum":"f19673443e9af799b730e3d4eca6a89b39e5a36825015dffd00d02ea3365cf74"},
{"name":"selenium-webdriver","version":"4.21.1","platform":"ruby","checksum":"c30b64014532fc5156c60797985f839f36adbe60ff4653e7112b008dc1c83263"},
{"name":"selenium-webdriver","version":"4.23.0","platform":"ruby","checksum":"490aeddee879cfea58a4db6628338d60a905bc56cd5e1a60dfbaa9090a19b801"},
{"name":"semver_dialects","version":"3.4.0","platform":"ruby","checksum":"9625fd343cd47335961ccd71249ad905b1a7c03c514a031b35540bc2946eab59"},
{"name":"sentry-rails","version":"5.17.3","platform":"ruby","checksum":"017771c42d739c0ad2213a581ca9d005cf543227bc13662cd1ca9909f2429459"},
{"name":"sentry-ruby","version":"5.17.3","platform":"ruby","checksum":"61791a4b0bb0f95cd87aceeaa1efa6d4ab34d64236c9d5df820478adfe2fbbfc"},
@ -706,7 +699,7 @@
{"name":"term-ansicolor","version":"1.7.1","platform":"ruby","checksum":"92339ffec77c4bddc786a29385c91601dd52fc68feda23609bba0491229b05f7"},
{"name":"terminal-table","version":"3.0.2","platform":"ruby","checksum":"f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91"},
{"name":"terser","version":"1.0.2","platform":"ruby","checksum":"80c2e0bc7e2db4e12e8529658f9e0820e13d685ae67d745bf981f269743bb28e"},
{"name":"test-prof","version":"1.3.3","platform":"ruby","checksum":"0f03ee4f84c22a8cf6caaa8f8f93987a15b31b789b596d0cf8cf8a8a8fc19667"},
{"name":"test-prof","version":"1.3.3.1","platform":"ruby","checksum":"0eb5dfdd6c2f70a8f1402683793e60b3ff63582a31690dfd8ef0aefd2c99b153"},
{"name":"test_file_finder","version":"0.3.1","platform":"ruby","checksum":"83fb0588a06b2784b51892910b9bfd06609f8d31f2d851a98d976f644d177199"},
{"name":"text","version":"1.3.1","platform":"ruby","checksum":"2fbbbc82c1ce79c4195b13018a87cbb00d762bda39241bb3cdc32792759dd3f4"},
{"name":"thor","version":"1.3.1","platform":"ruby","checksum":"fa7e3471d4f6a27138e3d9c9b0d4daac9c3d7383927667ae83e9ab42ae7401ef"},

View File

@ -201,9 +201,9 @@ GEM
CFPropertyList (3.0.5)
rexml
RedCloth (4.3.3)
acme-client (2.0.11)
acme-client (2.0.18)
faraday (>= 1.0, < 3.0.0)
faraday-retry (~> 1.0)
faraday-retry (>= 1.0, < 3.0.0)
actioncable (7.0.8.4)
actionpack (= 7.0.8.4)
activesupport (= 7.0.8.4)
@ -339,14 +339,6 @@ GEM
descendants_tracker (~> 0.0.4)
ice_nine (~> 0.11.0)
thread_safe (~> 0.3, >= 0.3.1)
azure-storage-blob (2.0.3)
azure-storage-common (~> 2.0)
nokogiri (~> 1, >= 1.10.8)
azure-storage-common (2.0.4)
faraday (~> 1.0)
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
babosa (2.0.0)
backport (1.2.0)
base32 (0.3.2)
@ -543,18 +535,19 @@ GEM
ecma-re-validator (0.3.0)
regexp_parser (~> 2.0)
ed25519 (1.3.0)
elasticsearch (7.13.3)
elasticsearch-api (= 7.13.3)
elasticsearch-transport (= 7.13.3)
elasticsearch-api (7.13.3)
elasticsearch (7.17.11)
elasticsearch-api (= 7.17.11)
elasticsearch-transport (= 7.17.11)
elasticsearch-api (7.17.11)
multi_json
elasticsearch-model (7.2.0)
elasticsearch-model (7.2.1)
activesupport (> 3)
elasticsearch (~> 7)
hashie
elasticsearch-rails (7.2.1)
elasticsearch-transport (7.13.3)
faraday (~> 1)
elasticsearch-transport (7.17.11)
base64
faraday (>= 1, < 3)
multi_json
email_reply_trimmer (0.1.6)
email_spec (2.2.0)
@ -580,36 +573,28 @@ GEM
factory_bot_rails (6.4.3)
factory_bot (~> 6.4)
railties (>= 5.0.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday (2.10.0)
faraday-net_http (>= 2.0, < 3.2)
logger
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-http-cache (2.5.0)
faraday (>= 0.8)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
faraday_middleware-aws-sigv4 (0.3.0)
faraday-net_http (3.1.0)
net-http
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.2.1)
faraday (~> 2.0)
faraday-typhoeus (1.1.0)
faraday (~> 2.0)
typhoeus (~> 1.4)
faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
faraday (>= 0.15)
faraday (>= 2.0, < 3)
fast_blank (1.0.1)
fast_gettext (2.3.0)
ffaker (2.23.0)
@ -717,12 +702,15 @@ GEM
gitlab-experiment (0.9.1)
activesupport (>= 3.0)
request_store (>= 1.0)
gitlab-fog-azure-rm (1.9.1)
azure-storage-blob (~> 2.0)
azure-storage-common (~> 2.0)
gitlab-fog-azure-rm (2.0.1)
faraday (~> 2.0)
faraday-follow_redirects (~> 0.3.0)
faraday-net_http_persistent (~> 2.0)
fog-core (~> 2.1)
fog-json (~> 1.2)
mime-types
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
gitlab-glfm-markdown (0.0.17)
rb_sys (= 0.9.94)
gitlab-labkit (0.36.1)
@ -889,9 +877,8 @@ GEM
rack
graphiql-rails (1.10.0)
railties
graphlient (0.6.0)
faraday (>= 1.0)
faraday_middleware
graphlient (0.8.0)
faraday (~> 2.0)
graphql-client
graphlyte (1.0.0)
graphql (2.3.5)
@ -1634,8 +1621,8 @@ GEM
prism (>= 0.29.0, < 0.31)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.7)
ruby-lsp (>= 0.17.0, < 0.18.0)
ruby-lsp-rails (0.3.8)
ruby-lsp (>= 0.17.2, < 0.18.0)
ruby-lsp-rspec (0.1.12)
ruby-lsp (~> 0.17.0)
ruby-magic (0.6.0)
@ -1671,8 +1658,9 @@ GEM
seed-fu (2.3.7)
activerecord (>= 3.1)
activesupport (>= 3.1)
selenium-webdriver (4.21.1)
selenium-webdriver (4.23.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
@ -1790,7 +1778,7 @@ GEM
unicode-display_width (>= 1.1.1, < 3)
terser (1.0.2)
execjs (>= 0.3.0, < 3)
test-prof (1.3.3)
test-prof (1.3.3.1)
test_file_finder (0.3.1)
faraday (>= 1.0, < 3.0, != 2.0.0)
text (1.3.1)
@ -1936,7 +1924,7 @@ PLATFORMS
DEPENDENCIES
CFPropertyList (~> 3.0.0)
RedCloth (~> 4.3.3)
acme-client (~> 2.0)
acme-client (~> 2.0.18)
activerecord-explain-analyze (~> 0.1)
activerecord-gitlab!
acts-as-taggable-on (~> 10.0)
@ -2003,15 +1991,18 @@ DEPENDENCIES
doorkeeper-openid_connect (~> 1.8, >= 1.8.7)
duo_api (~> 1.3)
ed25519 (~> 1.3.0)
elasticsearch-api (= 7.13.3)
elasticsearch-api (= 7.17.11)
elasticsearch-model (~> 7.2)
elasticsearch-rails (~> 7.2)
email_reply_trimmer (~> 0.1)
email_spec (~> 2.2.0)
error_tracking_open_api!
factory_bot_rails (~> 6.4.3)
faraday (~> 1.10.3)
faraday_middleware-aws-sigv4 (~> 0.3.0)
faraday (~> 2)
faraday-multipart (~> 1.0)
faraday-retry (~> 2)
faraday-typhoeus (~> 1.1)
faraday_middleware-aws-sigv4 (~> 1.0.1)
fast_blank (~> 1.0.1)
ffaker (~> 2.23)
flipper (~> 0.26.2)
@ -2032,7 +2023,7 @@ DEPENDENCIES
gitlab-chronic (~> 0.10.5)
gitlab-dangerfiles (~> 4.8.0)
gitlab-experiment (~> 0.9.1)
gitlab-fog-azure-rm (~> 1.9.1)
gitlab-fog-azure-rm (~> 2.0.1)
gitlab-glfm-markdown (~> 0.0.17)
gitlab-housekeeper!
gitlab-http!
@ -2078,7 +2069,7 @@ DEPENDENCIES
grape-swagger-entity (~> 0.5.1)
grape_logging (~> 1.8, >= 1.8.4)
graphiql-rails (~> 1.10)
graphlient (~> 0.6.0)
graphlient (~> 0.8.0)
graphlyte (~> 1.0.0)
graphql (~> 2.3.5)
graphql-docs (~> 5.0.0)

View File

@ -1,20 +1,26 @@
<script>
import { GlEmptyState, GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
import { GlEmptyState, GlSprintf, GlLink, GlAlert, GlDrawer } from '@gitlab/ui';
import CLUSTER_EMPTY_SVG from '@gitlab/svgs/dist/illustrations/empty-state/empty-state-clusters.svg?url';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import { s__ } from '~/locale';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { helpPagePath } from '~/helpers/help_page_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { k8sResourceType } from '~/environments/graphql/resolvers/kubernetes/constants';
import { createK8sAccessConfiguration } from '~/environments/helpers/k8s_integration_helper';
import {
createK8sAccessConfiguration,
fluxSyncStatus,
} from '~/environments/helpers/k8s_integration_helper';
import fluxKustomizationQuery from '~/environments/graphql/queries/flux_kustomization.query.graphql';
import fluxHelmReleaseQueryStatus from '~/environments/graphql/queries/flux_helm_release_status.query.graphql';
import fluxHelmReleaseQueryStatus from '~/environments/graphql/queries/flux_helm_release.query.graphql';
import {
CLUSTER_HEALTH_SUCCESS,
CLUSTER_HEALTH_ERROR,
HELM_RELEASES_RESOURCE_TYPE,
KUSTOMIZATIONS_RESOURCE_TYPE,
} from '~/environments/constants';
import WorkloadDetails from '~/kubernetes_dashboard/components/workload_details.vue';
import KubernetesStatusBar from './kubernetes_status_bar.vue';
import KubernetesAgentInfo from './kubernetes_agent_info.vue';
import KubernetesTabs from './kubernetes_tabs.vue';
@ -25,9 +31,11 @@ export default {
KubernetesStatusBar,
KubernetesAgentInfo,
KubernetesTabs,
WorkloadDetails,
GlSprintf,
GlLink,
GlAlert,
GlDrawer,
},
inject: ['kasTunnelUrl'],
props: {
@ -69,7 +77,7 @@ export default {
this.fluxApiError = err.message;
},
},
fluxHelmReleaseStatus: {
fluxHelmRelease: {
query: fluxHelmReleaseQueryStatus,
variables() {
return {
@ -94,6 +102,8 @@ export default {
podsLoading: false,
activeTab: k8sResourceType.k8sPods,
fluxApiError: '',
selectedItem: {},
showDetailsDrawer: false,
};
},
computed: {
@ -116,7 +126,13 @@ export default {
return Object.values(this.failedState).some((item) => item);
},
fluxResourceStatus() {
return this.fluxKustomization?.conditions || this.fluxHelmReleaseStatus?.conditions;
return this.fluxKustomization?.conditions || this.fluxHelmRelease?.conditions;
},
drawerHeaderHeight() {
return getContentWrapperHeight();
},
hasSelectedItem() {
return Object.keys(this.selectedItem).length;
},
},
methods: {
@ -134,6 +150,37 @@ export default {
...event,
};
},
transformFluxResourceData(item) {
return {
name: item.metadata.name,
status: fluxSyncStatus(item.status.conditions).status,
labels: item.metadata.labels,
annotations: item.metadata.annotations,
kind: item.kind,
spec: item.spec,
fullStatus: item.status.conditions,
};
},
showFluxResourceDetails() {
const fluxResource = this.fluxKustomization || this.fluxHelmRelease;
const fluxResourceTransformed = this.transformFluxResourceData(fluxResource);
this.openDetailsDrawer(fluxResourceTransformed);
},
openDetailsDrawer(item) {
this.selectedItem = item;
this.showDetailsDrawer = true;
this.$nextTick(() => {
this.$refs.drawer?.$el?.querySelector('button')?.focus();
});
},
closeDetailsDrawer() {
this.showDetailsDrawer = false;
this.selectedItem = {};
this.$nextTick(() => {
this.$refs.status_bar?.$refs?.flux_status_badge?.$el?.focus();
});
},
},
i18n: {
emptyTitle: s__('Environment|No Kubernetes clusters configured'),
@ -145,6 +192,7 @@ export default {
learnMoreLink: helpPagePath('user/clusters/agent/index'),
getStartedLink: helpPagePath('ci/environments/kubernetes_dashboard'),
CLUSTER_EMPTY_SVG,
DRAWER_Z_INDEX,
};
</script>
<template>
@ -154,6 +202,7 @@ export default {
>
<kubernetes-agent-info :cluster-agent="clusterAgent" class="gl-mb-2 gl-mr-5" />
<kubernetes-status-bar
ref="status_bar"
:cluster-health-status="clusterHealthStatus"
:configuration="k8sAccessConfiguration"
:namespace="kubernetesNamespace"
@ -163,6 +212,7 @@ export default {
:flux-resource-status="fluxResourceStatus"
:flux-api-error="fluxApiError"
@error="handleError"
@show-flux-resource-details="showFluxResourceDetails"
/>
</div>
@ -178,7 +228,26 @@ export default {
@cluster-error="handleError"
@loading="podsLoading = $event"
@update-failed-state="handleFailedState"
@show-resource-details="openDetailsDrawer"
@remove-selection="closeDetailsDrawer"
/>
<gl-drawer
ref="drawer"
:open="showDetailsDrawer"
:header-height="drawerHeaderHeight"
:z-index="$options.DRAWER_Z_INDEX"
@close="closeDetailsDrawer"
>
<template #title>
<h2 class="gl-font-bold gl-m-0 gl-break-anywhere">
{{ selectedItem.name }}
</h2>
</template>
<template #default>
<workload-details v-if="hasSelectedItem" :item="selectedItem" />
</template>
</gl-drawer>
</div>
<gl-empty-state
v-else

View File

@ -129,10 +129,10 @@ export default {
return `${this.environmentName}-flux-sync-badge`;
},
syncStatusBadge() {
if (!this.fluxResourceStatus.length && this.fluxApiError) {
if (!this.fluxResourcePresent && this.fluxApiError) {
return { ...SYNC_STATUS_BADGES.unavailable, popoverText: this.fluxApiError };
}
if (!this.fluxResourceStatus.length) {
if (!this.fluxResourcePresent) {
return SYNC_STATUS_BADGES.unavailable;
}
@ -165,11 +165,22 @@ export default {
isFluxConnectionStatus() {
return Boolean(this.fluxConnectionParams.resourceType);
},
fluxResourcePresent() {
return Boolean(this.fluxResourceStatus?.length);
},
fluxBadgeHref() {
return this.fluxResourcePresent ? '#' : null;
},
},
methods: {
handleError(error) {
this.$emit('error', error);
},
toggleFluxResource() {
if (!this.fluxResourcePresent) return;
this.$emit('show-flux-resource-details');
},
},
i18n: {
healthLabel: s__('Environment|Environment status'),
@ -216,10 +227,13 @@ export default {
<template v-else>
<gl-badge
:id="fluxBadgeId"
ref="flux_status_badge"
:icon="syncStatusBadge.icon"
:variant="syncStatusBadge.variant"
data-testid="sync-badge"
tabindex="0"
:href="fluxBadgeHref"
@click.native="toggleFluxResource"
>{{ syncStatusBadge.text }}
</gl-badge>
<gl-popover :target="fluxBadgeId" :title="syncStatusBadge.popoverTitle">

View File

@ -1,10 +1,7 @@
<script>
import { GlTabs, GlDrawer } from '@gitlab/ui';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import { GlTabs } from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { k8sResourceType } from '~/environments/graphql/resolvers/kubernetes/constants';
import WorkloadDetails from '~/kubernetes_dashboard/components/workload_details.vue';
import KubernetesPods from './kubernetes_pods.vue';
import KubernetesServices from './kubernetes_services.vue';
import KubernetesSummary from './kubernetes_summary.vue';
@ -18,8 +15,6 @@ export default {
KubernetesPods,
KubernetesServices,
KubernetesSummary,
GlDrawer,
WorkloadDetails,
},
mixins: [glFeatureFlagMixin()],
props: {
@ -46,14 +41,9 @@ export default {
activeTabIndex: this.glFeatures.k8sTreeView
? tabsWithSummary.indexOf(this.value)
: defaultTabs.indexOf(this.value),
selectedItem: {},
showDetailsDrawer: false,
};
},
computed: {
drawerHeaderHeight() {
return getContentWrapperHeight();
},
renderTreeView() {
return this.glFeatures.k8sTreeView;
},
@ -66,16 +56,6 @@ export default {
this.$emit('input', this.tabs[newValue]);
},
},
methods: {
showResourceDetails(item) {
this.selectedItem = item;
this.showDetailsDrawer = true;
},
closeDetailsDrawer() {
this.showDetailsDrawer = false;
},
},
DRAWER_Z_INDEX,
};
</script>
<template>
@ -94,33 +74,17 @@ export default {
@loading="$emit('loading', $event)"
@update-failed-state="$emit('update-failed-state', $event)"
@cluster-error="$emit('cluster-error', $event)"
@show-resource-details="showResourceDetails"
@remove-selection="closeDetailsDrawer"
@show-resource-details="$emit('show-resource-details', $event)"
@remove-selection="$emit('remove-selection')"
/>
<kubernetes-services
:namespace="namespace"
:configuration="configuration"
@cluster-error="$emit('cluster-error', $event)"
@show-resource-details="showResourceDetails"
@remove-selection="closeDetailsDrawer"
@show-resource-details="$emit('show-resource-details', $event)"
@remove-selection="$emit('remove-selection')"
/>
</gl-tabs>
<gl-drawer
:open="showDetailsDrawer"
:header-height="drawerHeaderHeight"
:z-index="$options.DRAWER_Z_INDEX"
@close="closeDetailsDrawer"
>
<template #title>
<h4 class="gl-font-bold gl-font-size-h2 gl-m-0 gl-break-anywhere">
{{ selectedItem.name }}
</h4>
</template>
<template #default>
<workload-details :item="selectedItem" />
</template>
</gl-drawer>
</div>
</template>

View File

@ -12,7 +12,7 @@ import k8sServicesQuery from './queries/k8s_services.query.graphql';
import k8sDeploymentsQuery from './queries/k8s_deployments.query.graphql';
import k8sNamespacesQuery from './queries/k8s_namespaces.query.graphql';
import fluxKustomizationQuery from './queries/flux_kustomization.query.graphql';
import fluxHelmReleaseStatusQuery from './queries/flux_helm_release_status.query.graphql';
import fluxHelmReleaseQuery from './queries/flux_helm_release.query.graphql';
import { resolvers } from './resolvers';
import typeDefs from './typedefs.graphql';
import { connectionStatus } from './resolvers/kubernetes/constants';
@ -132,10 +132,8 @@ export const apolloProvider = (endpoint) => {
cache.writeQuery({
query: fluxKustomizationQuery,
data: {
...k8sData,
kind: '',
metadata: {
name: '',
},
conditions: {
message: '',
reason: '',
@ -146,8 +144,10 @@ export const apolloProvider = (endpoint) => {
},
});
cache.writeQuery({
query: fluxHelmReleaseStatusQuery,
query: fluxHelmReleaseQuery,
data: {
...k8sData,
kind: '',
conditions: {
message: '',
reason: '',

View File

@ -0,0 +1,14 @@
#import "~/kubernetes_dashboard/graphql/queries/workload_item.fragment.graphql"
query getFluxHelmReleaseQuery($configuration: LocalConfiguration, $fluxResourcePath: String) {
fluxHelmRelease(configuration: $configuration, fluxResourcePath: $fluxResourcePath) @client {
...WorkloadItem
kind
conditions {
message
reason
status
type
}
}
}

View File

@ -1,11 +0,0 @@
query getFluxHelmReleaseStatusQuery($configuration: LocalConfiguration, $fluxResourcePath: String) {
fluxHelmReleaseStatus(configuration: $configuration, fluxResourcePath: $fluxResourcePath)
@client {
conditions {
message
reason
status
type
}
}
}

View File

@ -1,9 +1,9 @@
#import "~/kubernetes_dashboard/graphql/queries/workload_item.fragment.graphql"
query getFluxKustomizationQuery($configuration: LocalConfiguration, $fluxResourcePath: String) {
fluxKustomization(configuration: $configuration, fluxResourcePath: $fluxResourcePath) @client {
...WorkloadItem
kind
metadata {
name
}
conditions {
message
reason

View File

@ -7,12 +7,12 @@ import {
import { updateConnectionStatus } from '~/environments/graphql/resolvers/kubernetes/k8s_connection_status';
import { connectionStatus } from '~/environments/graphql/resolvers/kubernetes/constants';
import fluxKustomizationQuery from '../queries/flux_kustomization.query.graphql';
import fluxHelmReleaseStatusQuery from '../queries/flux_helm_release_status.query.graphql';
import fluxHelmReleaseQuery from '../queries/flux_helm_release.query.graphql';
const helmReleasesApiVersion = 'helm.toolkit.fluxcd.io/v2beta1';
const kustomizationsApiVersion = 'kustomize.toolkit.fluxcd.io/v1';
const helmReleaseField = 'fluxHelmReleaseStatus';
const helmReleaseField = 'fluxHelmRelease';
const kustomizationField = 'fluxKustomization';
const handleClusterError = (err) => {
@ -29,19 +29,25 @@ export const buildFluxResourceWatchPath = ({ namespace, apiVersion, resourceType
};
const mapFluxItems = (fluxItem, resourceType) => {
if (resourceType === KUSTOMIZATIONS_RESOURCE_TYPE) {
return {
kind: fluxItem?.kind || '',
metadata: {
name: fluxItem?.metadata?.name || '',
},
conditions: fluxItem?.status?.conditions || [],
inventory: fluxItem?.status?.inventory?.entries || [],
};
}
return {
conditions: fluxItem?.status?.conditions || [],
const metadata = {
...fluxItem.metadata,
annotations: fluxItem.metadata?.annotations || {},
labels: fluxItem.metadata?.labels || {},
};
const result = {
kind: fluxItem?.kind || '',
status: fluxItem.status || {},
spec: fluxItem.spec || {},
metadata,
conditions: fluxItem.status?.conditions || [],
__typename: 'LocalWorkloadItem',
};
if (resourceType === KUSTOMIZATIONS_RESOURCE_TYPE) {
result.inventory = fluxItem.status?.inventory?.entries || [];
}
return result;
};
const watchFluxResource = ({
@ -146,7 +152,7 @@ export const watchFluxKustomization = ({ configuration, client, fluxResourcePath
};
export const watchFluxHelmRelease = ({ configuration, client, fluxResourcePath }) => {
const query = fluxHelmReleaseStatusQuery;
const query = fluxHelmReleaseQuery;
const variables = { configuration, fluxResourcePath };
const field = helmReleaseField;
const resourceType = HELM_RELEASES_RESOURCE_TYPE;
@ -189,9 +195,9 @@ export default {
client,
});
},
fluxHelmReleaseStatus(_, { configuration, fluxResourcePath }, { client }) {
fluxHelmRelease(_, { configuration, fluxResourcePath }, { client }) {
return getFluxResource({
query: fluxHelmReleaseStatusQuery,
query: fluxHelmReleaseQuery,
variables: { configuration, fluxResourcePath },
field: helmReleaseField,
resourceType: HELM_RELEASES_RESOURCE_TYPE,

View File

@ -70,10 +70,6 @@ type LocalK8sNamespaces {
metadata: k8sNamespaceMetadata
}
type LocalFluxResourceMetadata {
name: String
}
type LocalFluxResourceConditions {
message: String
reason: String
@ -87,11 +83,17 @@ type LocalFluxResourceInventoryItem {
type LocalFluxKustomization {
kind: String
metadata: LocalFluxResourceMetadata
metadata: LocalWorkloadMetadata
status: JSON
spec: JSON
conditions: [LocalFluxResourceConditions]
inventory: [LocalFluxResourceInventoryItem]
}
type LocalFluxHelmReleaseStatus {
type LocalFluxHelmRelease {
kind: String
metadata: LocalWorkloadMetadata
status: JSON
spec: JSON
conditions: [LocalFluxResourceConditions]
}
@ -132,10 +134,7 @@ extend type Query {
configuration: LocalConfiguration
fluxResourcePath: String
): LocalFluxKustomization
fluxHelmReleaseStatus(
configuration: LocalConfiguration
fluxResourcePath: String
): LocalFluxHelmReleaseStatus
fluxHelmRelease(configuration: LocalConfiguration, fluxResourcePath: String): LocalFluxHelmRelease
k8sDeployments(configuration: LocalConfiguration, namespace: String): [LocalK8sDeployment]
k8sLogs(configuration: LocalConfiguration, namespace: String, podName: String): [K8sLogsData]
}

View File

@ -1,12 +1,11 @@
<script>
import { GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
import { issuableTypeText } from '~/issues/constants';
import { __, sprintf } from '~/locale';
export default {
components: {
GlBadge,
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -29,8 +28,12 @@ export default {
</script>
<template>
<gl-badge v-gl-tooltip :title="title" variant="warning" class="gl-shrink-0">
<gl-icon name="spam" />
<span class="gl-sr-only">{{ __('Hidden') }}</span>
</gl-badge>
<gl-badge
v-gl-tooltip
icon="spam"
:title="title"
:aria-label="__('Hidden')"
variant="warning"
class="gl-shrink-0"
/>
</template>

View File

@ -1,12 +1,11 @@
<script>
import { GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
import { issuableTypeText } from '~/issues/constants';
import { __, sprintf } from '~/locale';
export default {
components: {
GlBadge,
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -34,12 +33,11 @@ export default {
<template>
<gl-badge
v-gl-tooltip
icon="lock"
:title="title"
:aria-label="title"
variant="warning"
data-testid="locked-badge"
class="gl-shrink-0"
>
<gl-icon name="lock" />
<span class="gl-sr-only">{{ __('Locked') }}</span>
</gl-badge>
/>
</template>

View File

@ -1,5 +1,5 @@
<script>
import { GlBadge, GlIcon } from '@gitlab/ui';
import { GlBadge } from '@gitlab/ui';
import { __ } from '~/locale';
import {
STATUS_CLOSED,
@ -68,7 +68,6 @@ const badgePropertiesMap = {
export default {
components: {
GlBadge,
GlIcon,
},
props: {
issuableType: {
@ -93,10 +92,10 @@ export default {
<template>
<gl-badge
:variant="badgeProperties.variant"
:icon="badgeProperties.icon"
:aria-label="badgeProperties.text"
class="gl-shrink-0"
>
<gl-icon :name="badgeProperties.icon" />
{{ badgeProperties.text }}
</gl-badge>
</template>

View File

@ -113,6 +113,7 @@ export default {
this.loading = true;
const oauthAuthorizeURL = await this.getOauthAuthorizeURL();
// eslint-disable-next-line no-restricted-properties
window.open(oauthAuthorizeURL, I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, OAUTH_WINDOW_OPTIONS);
} catch (e) {
if (e.message) {

View File

@ -3,7 +3,7 @@ import { GlBadge, GlTruncate } from '@gitlab/ui';
import { stringify } from 'yaml';
import { s__ } from '~/locale';
import PodLogsButton from '~/environments/environment_details/components/kubernetes/pod_logs_button.vue';
import { WORKLOAD_STATUS_BADGE_VARIANTS } from '../constants';
import { WORKLOAD_STATUS_BADGE_VARIANTS, STATUS_LABELS } from '../constants';
import WorkloadDetailsItem from './workload_details_item.vue';
export default {
@ -73,6 +73,7 @@ export default {
containers: s__('KubernetesDashboard|Containers'),
},
WORKLOAD_STATUS_BADGE_VARIANTS,
STATUS_LABELS,
};
</script>
@ -86,21 +87,21 @@ export default {
</workload-details-item>
<workload-details-item v-if="itemLabels.length" :label="$options.i18n.labels">
<div class="gl-display-flex gl-flex-wrap gl-gap-2">
<gl-badge v-for="label of itemLabels" :key="label" class="gl-max-w-full">
<gl-badge v-for="label of itemLabels" :key="label" class="gl-max-w-full gl-w-auto">
<gl-truncate :text="label" with-tooltip />
</gl-badge>
</div>
</workload-details-item>
<workload-details-item v-if="item.status && !item.fullStatus" :label="$options.i18n.status">
<gl-badge :variant="$options.WORKLOAD_STATUS_BADGE_VARIANTS[item.status]">{{
item.status
$options.STATUS_LABELS[item.status]
}}</gl-badge>
</workload-details-item>
<workload-details-item v-if="item.fullStatus" :label="$options.i18n.status" collapsible>
<template v-if="item.status" #label>
<span class="gl-mr-2 gl-font-bold">{{ $options.i18n.status }}</span>
<gl-badge :variant="$options.WORKLOAD_STATUS_BADGE_VARIANTS[item.status]">{{
item.status
$options.STATUS_LABELS[item.status]
}}</gl-badge>
</template>
<pre>{{ statusYaml }}</pre>

View File

@ -7,6 +7,11 @@ export const STATUS_FAILED = 'Failed';
export const STATUS_READY = 'Ready';
export const STATUS_COMPLETED = 'Completed';
export const STATUS_SUSPENDED = 'Suspended';
export const STATUS_RECONCILED = 'reconciled';
export const STATUS_RECONCILING = 'reconciling';
export const STATUS_STALLED = 'stalled';
export const STATUS_UNKNOWN = 'unknown';
export const STATUS_UNAVAILABLE = 'unavailable';
export const STATUS_LABELS = {
[STATUS_RUNNING]: s__('KubernetesDashboard|Running'),
@ -16,6 +21,12 @@ export const STATUS_LABELS = {
[STATUS_READY]: s__('KubernetesDashboard|Ready'),
[STATUS_COMPLETED]: s__('KubernetesDashboard|Completed'),
[STATUS_SUSPENDED]: s__('KubernetesDashboard|Suspended'),
[STATUS_RECONCILED]: s__('Environment|Reconciled'),
[STATUS_RECONCILING]: s__('Environment|Reconciling'),
[STATUS_STALLED]: s__('Environment|Stalled'),
[STATUS_UNKNOWN]: s__('Environment|Unknown'),
[STATUS_UNAVAILABLE]: s__('Environment|Unavailable'),
failed: s__('KubernetesDashboard|Failed'),
};
export const WORKLOAD_STATUS_BADGE_VARIANTS = {
@ -26,6 +37,12 @@ export const WORKLOAD_STATUS_BADGE_VARIANTS = {
[STATUS_READY]: 'success',
[STATUS_COMPLETED]: 'success',
[STATUS_SUSPENDED]: 'neutral',
[STATUS_RECONCILED]: 'success',
[STATUS_RECONCILING]: 'info',
[STATUS_STALLED]: 'warning',
[STATUS_UNKNOWN]: 'neutral',
[STATUS_UNAVAILABLE]: 'neutral',
failed: 'danger',
};
export const PAGE_SIZE = 20;

View File

@ -743,8 +743,10 @@ export function visitUrl(destination, openWindow = false) {
if (isExternal(url)) {
const target = openWindow ? '_blank' : '_self';
// Sets window.opener to null and avoids leaking referrer information.
// eslint-disable-next-line no-restricted-properties
window.open(url, target, 'noreferrer');
} else if (openWindow) {
// eslint-disable-next-line no-restricted-properties
window.open(url);
} else {
window.location.assign(url);

View File

@ -5,7 +5,7 @@ import { createAlert } from '~/alert';
import dateFormat from '~/lib/dateformat';
import axios from '~/lib/utils/axios_utils';
import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility';
import { formatDate } from '~/lib/utils/datetime/date_format_utility';
import { localeDateFormat } from '~/lib/utils/datetime/locale_dateformat';
import { n__, s__, __ } from '~/locale';
import { loadingIconForLegacyJS } from '~/loading_icon_for_legacy_js';
@ -289,7 +289,7 @@ export default class ActivityCalendar {
.querySelector(this.activitiesContainer)
.querySelectorAll('.js-localtime')
.forEach((el) => {
el.setAttribute('title', formatDate(el.dataset.datetime));
el.setAttribute('title', localeDateFormat.asDateTimeFull.format(el.dataset.datetime));
});
})
.catch(() =>

View File

@ -2,6 +2,7 @@ import { createAlert } from '~/alert';
import axios from './lib/utils/axios_utils';
import { parseBoolean } from './lib/utils/common_utils';
import { __ } from './locale';
import { visitUrl } from './lib/utils/url_utility';
const DEFERRED_LINK_CLASS = 'deferred-link';
@ -69,7 +70,7 @@ export default class PersistentUserCallout {
if (deferredLinkOptions) {
const { href, target } = deferredLinkOptions;
window.open(href, target);
visitUrl(href, target === '_blank');
}
})
.catch(() => {

View File

@ -1,8 +1,11 @@
<script>
import { GlTabs, GlTab, GlBadge, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import { joinPaths, updateHistory, pathSegments } from '~/lib/utils/url_utility';
import { PROJECT_DASHBOARD_TABS, CONTRIBUTED_TAB } from 'ee_else_ce/projects/your_work/constants';
import {
PROJECT_DASHBOARD_TABS,
CONTRIBUTED_TAB,
CUSTOM_DASHBOARD_ROUTE_NAMES,
} from 'ee_else_ce/projects/your_work/constants';
export default {
name: 'YourWorkProjectsApp',
@ -18,7 +21,7 @@ export default {
},
data() {
return {
activeTabIndex: 0,
activeTabIndex: this.initActiveTabIndex(),
};
},
computed: {
@ -26,21 +29,11 @@ export default {
return PROJECT_DASHBOARD_TABS.map((tab) => ({ ...tab, count: 0 }));
},
},
created() {
this.getTabFromUrl();
},
methods: {
getTabFromUrl() {
const tab = pathSegments(window.location)?.pop();
const tabIndex = PROJECT_DASHBOARD_TABS.findIndex(({ value }) => value === tab);
this.activeTabIndex = tabIndex > 0 ? tabIndex : 0;
},
setTabInUrl() {
const tab = PROJECT_DASHBOARD_TABS[this.activeTabIndex] || CONTRIBUTED_TAB;
const url = joinPaths(gon.relative_url_root || '/', `/dashboard/projects/${tab.value}`);
updateHistory({ url, replace: true });
initActiveTabIndex() {
return CUSTOM_DASHBOARD_ROUTE_NAMES.includes(this.$route.name)
? 0
: PROJECT_DASHBOARD_TABS.findIndex((tab) => tab.value === this.$route.name);
},
onTabUpdate(index) {
// This return will prevent us overwriting the root `/` and `/dashboard/projects` paths
@ -48,7 +41,9 @@ export default {
if (index === this.activeTabIndex) return;
this.activeTabIndex = index;
this.setTabInUrl();
const tab = PROJECT_DASHBOARD_TABS[index] || CONTRIBUTED_TAB;
this.$router.push({ name: tab.value });
},
},
};

View File

@ -21,3 +21,17 @@ export const MEMBER_TAB = {
};
export const PROJECT_DASHBOARD_TABS = [CONTRIBUTED_TAB, STARRED_TAB, PERSONAL_TAB, MEMBER_TAB];
export const BASE_ROUTE = '/dashboard/projects';
export const ROOT_ROUTE_NAME = 'root';
export const DASHBOARD_ROUTE_NAME = 'dashboard';
export const PROJECTS_DASHBOARD_ROUTE_NAME = 'projects-dashboard';
export const CUSTOM_DASHBOARD_ROUTE_NAMES = [
ROOT_ROUTE_NAME,
DASHBOARD_ROUTE_NAME,
PROJECTS_DASHBOARD_ROUTE_NAME,
];

View File

@ -1,6 +1,20 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
import routes from './routes';
import YourWorkProjectsApp from './components/app.vue';
Vue.use(VueRouter);
export const createRouter = () => {
const router = new VueRouter({
routes,
mode: 'history',
base: gon.relative_url_root || '/',
});
return router;
};
export const initYourWorkProjects = () => {
const el = document.getElementById('js-your-work-projects-app');
@ -8,6 +22,7 @@ export const initYourWorkProjects = () => {
return new Vue({
el,
router: createRouter(),
name: 'YourWorkProjectsRoot',
render(createElement) {
return createElement(YourWorkProjectsApp);

View File

@ -0,0 +1,26 @@
import {
BASE_ROUTE,
ROOT_ROUTE_NAME,
DASHBOARD_ROUTE_NAME,
PROJECTS_DASHBOARD_ROUTE_NAME,
PROJECT_DASHBOARD_TABS,
} from 'ee_else_ce/projects/your_work/constants';
export default [
{
name: ROOT_ROUTE_NAME,
path: '/',
},
{
name: DASHBOARD_ROUTE_NAME,
path: '/dashboard',
},
{
name: PROJECTS_DASHBOARD_ROUTE_NAME,
path: BASE_ROUTE,
},
...PROJECT_DASHBOARD_TABS.map(({ value }) => ({
name: value,
path: `${BASE_ROUTE}/${value}`,
})),
];

View File

@ -22,7 +22,7 @@ export default {
<slot v-if="$scopedSlots.heading" name="heading"></slot>
<template v-else>{{ heading }}</template>
</h2>
<p v-if="$scopedSlots.description || description" class="gl-text-secondary gl-m-0">
<p v-if="$scopedSlots.description || description" class="gl-text-secondary gl-mb-3">
<slot v-if="$scopedSlots.description" name="description"></slot>
<template v-else>{{ description }}</template>
</p>

View File

@ -16,6 +16,7 @@ import { __, n__, sprintf } from '~/locale';
import IssuableAssignees from '~/issuable/components/issue_assignees.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue';
import WorkItemPrefetch from '~/work_items/components/work_item_prefetch.vue';
import { STATE_OPEN, STATE_CLOSED } from '~/work_items/constants';
import { isAssigneesWidget, isLabelsWidget } from '~/work_items/utils';
@ -29,6 +30,7 @@ export default {
GlSprintf,
IssuableAssignees,
WorkItemTypeIcon,
WorkItemPrefetch,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -286,17 +288,38 @@ export default {
:title="__('This issue is hidden because its author has been banned.')"
:aria-label="__('Hidden')"
/>
<gl-link
class="issue-title-text gl-font-base"
dir="auto"
:href="issuableLinkHref"
data-testid="issuable-title-link"
v-bind="issuableTitleProps"
@click="handleIssuableItemClick"
>
{{ issuable.title }}
<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" />
</gl-link>
<template v-if="preventRedirect">
<work-item-prefetch :work-item-iid="issuableIid" data-testid="issuable-prefetch-trigger">
<template #default="{ prefetchWorkItem, clearPrefetching }">
<gl-link
class="issue-title-text gl-font-base"
dir="auto"
:href="issuableLinkHref"
data-testid="issuable-title-link"
v-bind="issuableTitleProps"
@click="handleIssuableItemClick"
@mouseover.native="prefetchWorkItem(issuableIid)"
@mouseout.native="clearPrefetching"
>
{{ issuable.title }}
<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" />
</gl-link>
</template>
</work-item-prefetch>
</template>
<template v-else>
<gl-link
class="issue-title-text gl-font-base"
dir="auto"
:href="issuableLinkHref"
data-testid="issuable-title-link"
v-bind="issuableTitleProps"
@click="handleIssuableItemClick"
>
{{ issuable.title }}
<gl-icon v-if="isIssuableUrlExternal" name="external-link" class="gl-ml-2" />
</gl-link>
</template>
<slot v-if="hasSlotContents('title-icons')" name="title-icons"></slot>
<span
v-if="taskStatus"

View File

@ -0,0 +1,62 @@
<script>
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql';
export default {
name: 'WorkItemPrefetch',
inject: {
fullPath: {
default: '',
},
},
props: {
workItemIid: {
type: String,
required: true,
},
},
data() {
return {
skipQuery: true,
};
},
apollo: {
workItem: {
query() {
return workItemByIidQuery;
},
variables() {
return {
fullPath: this.fullPath,
iid: this.workItemIid,
};
},
skip() {
return !this.fullPath || this.skipQuery;
},
update(data) {
return data.workspace.workItem ?? {};
},
},
},
methods: {
prefetchWorkItem() {
this.prefetch = setTimeout(() => {
this.skipQuery = false;
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
clearPrefetching() {
if (this.prefetch) {
clearTimeout(this.prefetch);
this.prefetch = null;
}
},
},
render() {
return this.$scopedSlots.default({
prefetchWorkItem: this.prefetchWorkItem,
clearPrefetching: this.clearPrefetching,
});
},
};
</script>

View File

@ -1,12 +1,11 @@
<script>
import { GlBadge, GlIcon } from '@gitlab/ui';
import { GlBadge } from '@gitlab/ui';
import { __ } from '~/locale';
import { STATE_OPEN } from '../constants';
export default {
components: {
GlBadge,
GlIcon,
},
props: {
workItemState: {
@ -32,8 +31,7 @@ export default {
</script>
<template>
<gl-badge :variant="workItemStateVariant" class="gl-align-middle">
<gl-icon :name="workItemStateIcon" :size="16" />
<span class="gl-sr-only sm:gl-not-sr-only gl-ml-2">{{ stateText }}</span>
<gl-badge :variant="workItemStateVariant" :icon="workItemStateIcon" class="gl-align-middle">
{{ stateText }}
</gl-badge>
</template>

View File

@ -4,7 +4,7 @@
%h2.gl-heading-2{ class: '!gl-mb-2' }
= heading || @heading
- if description || @description
%p.gl-text-secondary.gl-m-0
%p.gl-text-secondary.gl-mb-3
= description || @description
%div{ data: { testid: 'settings-section-body' } }
= body

View File

@ -25,7 +25,7 @@ module Mutations
def resolve(project_path:, name:)
project = authorized_find!(project_path)
result = ::Clusters::Agents::CreateService.new(project, current_user).execute(name: name)
result = ::Clusters::Agents::CreateService.new(project, current_user, { name: name }).execute
{
cluster_agent: result[:cluster_agent],

View File

@ -17,8 +17,8 @@ module Mutations
def resolve(id:)
cluster_agent = authorized_find!(id: id)
result = ::Clusters::Agents::DeleteService
.new(container: cluster_agent.project, current_user: current_user)
.execute(cluster_agent)
.new(container: cluster_agent.project, current_user: current_user, params: { cluster_agent: cluster_agent })
.execute
{
errors: Array.wrap(result.message)

View File

@ -972,6 +972,9 @@ class Group < Namespace
# NOTE: We still want to keep this after removing `Namespace#feature_available?`.
override :feature_available?
def feature_available?(feature, user = nil)
# when we check the :issues feature at group level we need to check the `epics` license feature instead
feature = feature == :issues ? :epics : feature
if ::Groups::FeatureSetting.available_features.include?(feature)
group_feature.feature_available?(feature, user) # rubocop:disable Gitlab/FeatureAvailableUsage
else

View File

@ -92,6 +92,14 @@ module Import
end
end
def accepted_reassign_to_user
reassign_to_user if accepted_status?
end
def accepted_status?
reassignment_in_progress? || completed? || failed?
end
def reassignable_status?
pending_reassignment? || rejected?
end

View File

@ -769,6 +769,12 @@ class Issue < ApplicationRecord
project_id.blank?
end
def autoclose_by_merged_closing_merge_request?
return false if group_level?
project.autoclose_referenced_issues
end
private
def project_level_readable_by?(user)

View File

@ -14,6 +14,10 @@ class SystemNoteMetadata < ApplicationRecord
moved merge
label milestone
relate unrelate
unrelate_from_parent
unrelate_from_child
relate_to_parent
relate_to_child
cloned
].freeze

View File

@ -8,18 +8,10 @@ module WorkItems
end
def will_auto_close_by_merge_request
return false unless work_item.opened? && autoclose_referenced_issues_enabled?
return false unless work_item.opened? && work_item.autoclose_by_merged_closing_merge_request?
work_item.merge_requests_closing_issues.with_opened_merge_request.exists?
end
private
def autoclose_referenced_issues_enabled?
return true if work_item.project.nil?
work_item.project.autoclose_referenced_issues
end
end
end
end

View File

@ -65,6 +65,10 @@ class IssuePolicy < IssuablePolicy
prevent :read_issue
end
rule { can?(:read_issue) & notes_widget_enabled }.policy do
enable :read_note
end
rule { ~can?(:read_issue) }.policy do
prevent :create_note
prevent :read_note

View File

@ -3,15 +3,15 @@
module Clusters
module Agents
class CreateService < BaseService
def execute(name:)
def execute
return error_no_permissions unless cluster_agent_permissions?
agent = ::Clusters::Agent.new(name: name, project: project, created_by_user: current_user)
agent = ::Clusters::Agent.new(name: params[:name], project: project, created_by_user: current_user)
if agent.save
success.merge(cluster_agent: agent)
ServiceResponse.new(status: :success, payload: { cluster_agent: agent }, reason: :created)
else
error(agent.errors.full_messages)
ServiceResponse.error(message: agent.errors.full_messages)
end
end
@ -22,8 +22,12 @@ module Clusters
end
def error_no_permissions
error(s_('ClusterAgent|You have insufficient permissions to create a cluster agent for this project'))
ServiceResponse.error(
message: s_('ClusterAgent|You have insufficient permissions to create a cluster agent for this project')
)
end
end
end
end
Clusters::Agents::CreateService.prepend_mod

View File

@ -3,7 +3,9 @@
module Clusters
module Agents
class DeleteService < ::BaseContainerService
def execute(cluster_agent)
def execute
cluster_agent = params[:cluster_agent]
return error_no_permissions unless current_user.can?(:admin_cluster, cluster_agent)
if cluster_agent.destroy
@ -21,3 +23,5 @@ module Clusters
end
end
end
Clusters::Agents::DeleteService.prepend_mod

View File

@ -60,7 +60,7 @@ module MergeRequests
# as the worker only supports finding an Issue. We are also only experiencing
# SQL timeouts when closing an Issue.
if issue.is_a?(Issue)
next if issue.project.present? && !issue.project.autoclose_referenced_issues
next unless issue.autoclose_by_merged_closing_merge_request?
MergeRequests::CloseIssueWorker.perform_async(
project.id,

View File

@ -45,16 +45,16 @@ when 'lint'
uncompressed_files = Parallel.map(files) do |file|
is_uncompressed, _ = Tooling::Image.compress_image(file, true)
if is_uncompressed
puts "Uncompressed file detected: ".color(:red) + file
puts Rainbow("Uncompressed file detected: ").red + file
file
end
end.compact
if uncompressed_files.empty?
puts "All documentation images are optimally compressed!".color(:green)
puts Rainbow("All documentation images are optimally compressed!").green
else
warn(
"The #{uncompressed_files.size} image(s) above have not been optimally compressed using pngquant.".color(:red),
Rainbow("The #{uncompressed_files.size} image(s) above have not been optimally compressed using pngquant.").red,
'Please run "bin/pngquant compress" and commit the result.'
)
abort

View File

@ -5,6 +5,18 @@
require 'gitlab/current_settings'
Gitlab.ee do
require 'elasticsearch'
module Elasticsearch
class Client
# https://github.com/elastic/elasticsearch-ruby/commit/51edf86470dad9d0701fcbac69dae5b89227bc02 introduced
# a verification step on the Elasticsearch version. Disable this check to maintain compatibility with OpenSearch.
def verify_elasticsearch
@verified = true
end
end
end
require 'elasticsearch/model'
### Monkey patches

View File

@ -705,6 +705,8 @@
- 1
- - search_elastic_default_branch_changed
- 1
- - search_elastic_delete
- 1
- - search_elastic_group_association_deletion
- 1
- - search_elastic_trigger_indexing
@ -729,6 +731,8 @@
- 1
- - search_zoekt_project_transfer
- 1
- - security_create_security_policy_project
- 1
- - security_delete_orchestration_configuration
- 1
- - security_generate_policy_violation_comment

View File

@ -0,0 +1,12 @@
# Error: gitlab.InternalLinksCode
#
# Checks that internal links don't link to files outside the docs directory
#
# For a list of all options, see https://vale.sh/docs/topics/styles/
extends: existence
message: "Use full URLs for files outside the docs directory."
link: https://docs.gitlab.com/ee/development/documentation/styleguide/index.html#links
level: error
scope: raw
raw:
- '\[[^\]]*\]\([\.\/]*(ee|app|bin|config|db|data|fixtures|lib|locale|qa|scripts|spec)\/'

View File

@ -8644,6 +8644,30 @@ Input type: `SecurityPolicyProjectCreateInput`
| <a id="mutationsecuritypolicyprojectcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationsecuritypolicyprojectcreateproject"></a>`project` | [`Project`](#project) | Security Policy Project that was created. |
### `Mutation.securityPolicyProjectCreateAsync`
**Status:** Alpha. Creates and assigns a security policy project for the given project or group (`full_path`) async.
DETAILS:
**Introduced** in GitLab 17.3.
**Status**: Experiment.
Input type: `SecurityPolicyProjectCreateAsyncInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationsecuritypolicyprojectcreateasyncclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationsecuritypolicyprojectcreateasyncfullpath"></a>`fullPath` | [`String!`](#string) | Full path of the project or group. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationsecuritypolicyprojectcreateasyncclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationsecuritypolicyprojectcreateasyncerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
### `Mutation.securityPolicyProjectUnassign`
Unassigns the security policy project for the given project (`full_path`).
@ -27990,6 +28014,18 @@ Represents policy violation for `license_scanning` report_type.
| <a id="policylicensescanningviolationlicense"></a>`license` | [`String!`](#string) | License name. |
| <a id="policylicensescanningviolationurl"></a>`url` | [`String`](#string) | URL of the license. |
### `PolicyProjectCreated`
Response of security policy creation.
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="policyprojectcreatederrormessage"></a>`errorMessage` | [`String`](#string) | Error message in case status is :error. |
| <a id="policyprojectcreatedproject"></a>`project` | [`Project`](#project) | Security Policy Project that was created. |
| <a id="policyprojectcreatedstatus"></a>`status` | [`PolicyProjectCreatedStatus`](#policyprojectcreatedstatus) | Status of the creation of the security policy project. |
### `PolicyScanFindingViolation`
Represents policy violation for `scan_finding` report_type.
@ -36004,6 +36040,15 @@ Pipeline security report finding sort values.
| <a id="pipelinestatusenumwaiting_for_callback"></a>`WAITING_FOR_CALLBACK` | Pipeline is waiting for an external action. |
| <a id="pipelinestatusenumwaiting_for_resource"></a>`WAITING_FOR_RESOURCE` | A resource (for example, a runner) that the pipeline requires to run is unavailable. |
### `PolicyProjectCreatedStatus`
Types of security policy project created status.
| Value | Description |
| ----- | ----------- |
| <a id="policyprojectcreatedstatuserror"></a>`ERROR` | Creating the security policy project faild. |
| <a id="policyprojectcreatedstatussuccess"></a>`SUCCESS` | Creating the security policy project was successful. |
### `PolicyViolationErrorType`
| Value | Description |

View File

@ -349,14 +349,14 @@ After indexing is done, the index is ready for search.
### Adding a new scope to search service
Search data is available in [`SearchController`](../../app/controllers/search_controller.rb) and
[Search API](../../lib/api/search.rb). Both use the [`SearchService`](../../app/services/search_service.rb) to return results.
Search data is available in [`SearchController`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/controllers/search_controller.rb) and
[Search API](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/api/search.rb). Both use the [`SearchService`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/services/search_service.rb) to return results.
The `SearchService` can be used to return results outside of the `SearchController` and `Search API`.
#### Search scopes
The `SearchService` exposes searching at [global](../../app/services/search/global_service.rb),
[group](../../app/services/search/group_service.rb), and [project](../../app/services/search/project_service.rb) levels.
The `SearchService` exposes searching at [global](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/services/search/global_service.rb),
[group](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/services/search/group_service.rb), and [project](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/services/search/project_service.rb) levels.
New scopes must be added to the following constants:
@ -366,7 +366,7 @@ New scopes must be added to the following constants:
NOTE:
Global search can be disabled for a scope. Create an ops feature flag named `global_search_SCOPE_tab` that defaults to `true`
and add it to the `global_search_enabled_for_scope?` method in [`SearchService`](../../app/services/search_service.rb).
and add it to the `global_search_enabled_for_scope?` method in [`SearchService`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/services/search_service.rb).
#### Results classes

View File

@ -809,21 +809,24 @@ However, you should avoid putting too many links on any page. Too many links can
- Consider using [Related topics](../topic_types/index.md#related-topics) to reduce links that interrupt the flow of a task.
- Try to avoid anchor links to sections on the same page. Let users rely on the right navigation instead.
### Links within the same repository
### Links in the same repository
To link to another page in the same repository,
use a relative file path. For example, `../user/gitlab_com/index.md`.
To link to another documentation (`.md`) file in the same repository:
Use inline link Markdown markup `[Text](https://example.com)`,
rather than reference-style links, like `[Text][identifier]`.
- Use an inline link with a relative file path. For example, `[GitLab.com settings](../user/gitlab_com/index.md)`.
- Put the entire link on a single line, even if the link is very long. ([Vale](../testing/vale.md) rule: [`SubstitutionWarning.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/.vale/gitlab/MultiLineLinks.yml)).
Put the entire link on a single line so that [linters](../testing/index.md) can find it.
To link to a file outside of the documentation files, for example to link from development
documentation to a specific code file, you can:
- Use a full URL. For example: ``[`app/views/help/show.html.haml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/views/help/show.html.haml)``
- (Optional) Use a full URL with a specific ref. For example: ``[`app/views/help/show.html.haml`](https://gitlab.com/gitlab-org/gitlab/-/blob/6d01aa9f1cfcbdfa88edf9d003bd073f1a6fff1d/app/views/help/show.html.haml)``
### Links in separate repositories
To link to a page in a different repository, use an absolute URL.
To link to a page in a different repository, use a full URL.
For example, to link from a page in the GitLab repository to the Charts repository,
use a URL like `https://docs.gitlab.com/charts/`.
use a URL like `[GitLab Charts documentation](https://docs.gitlab.com/charts/)`.
### Anchor links
@ -851,7 +854,7 @@ any related links, search these directories:
- `ee/app/views/*`
If you do not fix these links, the [`ui-docs-lint` job](../testing/index.md#tests-in-ui-docs-links-lint)
in your merge request fails.
in your merge request might fail.
### Text for links

View File

@ -83,7 +83,7 @@ Documentation and References:
- **Amazon SageMaker Notebooks** [allow Git repositories to be specified by the Git clone URL](https://docs.aws.amazon.com/sagemaker/latest/dg/nbi-git-resource.html) and configuration of a secret - so GitLab is configurable. ([12/28/2023](https://aws.amazon.com/about-aws/whats-new/2023/12/codepipeline-gitlab-self-managed/)) `[AWS Configuration]`
- **AWS Amplify** - [uses a Git integration mechanism designed by the AWS Amplify team](https://docs.aws.amazon.com/amplify/latest/userguide/getting-started.html). `[AWS Built]`
- **AWS Glue Notebook Jobs** support for GitLab respository URL with Personal Access Token (PAT) authentication at the "job" level. ([10/03/2022](https://aws.amazon.com/about-aws/whats-new/2022/10/aws-glue-git-integration/)) [AWS Docs about configuring GitLab](https://docs.aws.amazon.com/glue/latest/dg/edit-job-add-source-control-integration.html) `[AWS Configuration]`
- **AWS Glue Notebook Jobs** support for GitLab repository URL with Personal Access Token (PAT) authentication at the "job" level. ([10/03/2022](https://aws.amazon.com/about-aws/whats-new/2022/10/aws-glue-git-integration/)) [AWS Docs about configuring GitLab](https://docs.aws.amazon.com/glue/latest/dg/edit-job-add-source-control-integration.html) `[AWS Configuration]`
#### Other SCM Integration Options

View File

@ -169,7 +169,6 @@ To change the location of project dashboards:
to create the project to store your dashboard files.
1. On the left sidebar, select **Search or go to** and find the analytics project.
1. Select **Settings > Analytics**.
1. Select **Expand** to see custom dashboard projects.
1. In the **Analytics Dashboards** section, select your dashboard files project.
1. Select **Save changes**.

View File

@ -97,8 +97,7 @@ DETAILS:
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/10779) in GitLab 16.7 as an [experiment](../../../policy/experiment-beta-support.md#experiment) on GitLab.com.
Use GitLab Duo Vulnerability resolution to automatically create a merge request that
resolves the vulnerability. By default, it is powered by Anthropic's [`claude-3-haiku`](https://docs.anthropic.com/en/docs/about-claude/models#claude-3-a-new-generation-of-ai)
model.
resolves the vulnerability. By default, it is powered by Anthropic's [`claude-3.5-sonnet`](https://console.cloud.google.com/vertex-ai/publishers/anthropic/model-garden/claude-3-5-sonnet) model.
We cannot guarantee that the large language model produces results that are correct. Use the
explanation with caution.

View File

@ -234,6 +234,10 @@ Audit event types belong to the following product categories.
| Name | Description | Saved to database | Streamed | Introduced in | Scope |
|:------------|:------------|:------------------|:---------|:--------------|:--------------|
| [`cluster_agent_create_failed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/159593) | Event triggered when a user attempts to create a cluster agent but it failed| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.3](https://gitlab.com/gitlab-org/gitlab/-/issues/462749) | Project |
| [`cluster_agent_created`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/159593) | Event triggered when a user creates a cluster agent| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.3](https://gitlab.com/gitlab-org/gitlab/-/issues/462749) | Project |
| [`cluster_agent_delete_failed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/159593) | Event triggered when a user attempts to delete a cluster agent but it failed| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.3](https://gitlab.com/gitlab-org/gitlab/-/issues/462749) | Project |
| [`cluster_agent_deleted`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/159593) | Event triggered when a user deletes a cluster agent| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.3](https://gitlab.com/gitlab-org/gitlab/-/issues/462749) | Project |
| [`cluster_agent_token_created`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/112036) | Event triggered when a user creates a cluster agent token| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.10](https://gitlab.com/gitlab-org/gitlab/-/issues/382133) | Project |
| [`cluster_agent_token_revoked`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/112036) | Event triggered when a user revokes a cluster agent token| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.10](https://gitlab.com/gitlab-org/gitlab/-/issues/382133) | Project |

View File

@ -211,7 +211,7 @@ DETAILS:
**Status:** Experiment
- Help resolve a vulnerability by generating a merge request that addresses it.
- LLM: Anthropic's [`claude-3-haiku`](https://docs.anthropic.com/en/docs/about-claude/models#claude-3-a-new-generation-of-ai).
- LLM: Anthropic's [`claude-3.5-sonnet`](https://console.cloud.google.com/vertex-ai/publishers/anthropic/model-garden/claude-3-5-sonnet).
- [View documentation](../application_security/vulnerabilities/index.md#vulnerability-resolution).
### Product Analytics

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@ -4,7 +4,7 @@ group: Optimize
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
---
# Group DevOps Adoption
# DevOps adoption by group
DETAILS:
**Tier:** Ultimate
@ -12,32 +12,20 @@ DETAILS:
> - [Added](https://gitlab.com/gitlab-org/gitlab/-/issues/367093) to the [Registration Features Program](../../../administration/settings/usage_statistics.md#registration-features-program) in GitLab 16.6.
DevOps Adoption shows you how groups in your organization adopt and use the most essential features of GitLab.
DevOps adoption shows you how groups in your organization adopt and use GitLab features.
This information is available for groups and [instances](../../../administration/analytics/dev_ops_reports.md).
You can use Group DevOps Adoption to:
Use DevOps adoption for groups to:
- Identify specific subgroups that are lagging in their adoption of GitLab features, so you can guide them on
- Identify subgroups that are lagging in their adoption of GitLab features, so you can guide them on
their DevOps journey.
- Find subgroups that have adopted certain features, and provide guidance to other subgroups on
how to use those features.
- Verify if you are getting the return on investment that you expected from GitLab.
![DevOps Adoption](img/group_devops_adoption_v14_2.png)
## Feature adoption
## View DevOps Adoption
Prerequisites:
- You must have at least the Reporter role for the group.
To view DevOps Adoption:
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Analyze > DevOps adoption**
## DevOps Adoption categories
DevOps Adoption shows feature adoption for development, security, and operations.
DevOps adoption shows feature adoption for development, security, and operations.
| Category | Feature |
|-------------|---------|
@ -45,48 +33,69 @@ DevOps Adoption shows feature adoption for development, security, and operations
| Security | DAST<br>Dependency Scanning<br>Fuzz Testing<br>SAST |
| Operations | Deployments<br>Pipelines<br>Runners |
## Feature adoption
A feature shows as **adopted** when a group or subgroup has used the feature in a project in the last full calendar month.
For example, if an issue was created in a project in a group, the group has adopted issues in that time.
DevOps Adoption shows feature adoption data for groups and subgroups for the previous calendar month.
The DevOps adoption report excludes:
A feature shows as **adopted** when a group has used the feature in a project during the time period.
This includes projects in any subgroups of the group. For example, if an issue was created in a project in a group, the group has adopted issues in that time.
### Exceptions to feature adoption data
When GitLab measures DevOps Adoption, some common DevOps information is not included:
- Dormant projects. It doesn't matter how many projects in the group use a feature. Even if you have many dormant projects, it doesn't lower the adoption.
- Dormant projects. The number of projects that use a feature is not considered. Having many dormant projects doesn't lower the adoption.
- New GitLab features. Adoption is the total number of features adopted, not the percent of features.
## When DevOps Adoption data is gathered
## Data processing
A weekly task processes data for DevOps Adoption. This task is disabled until you access
DevOps Adoption for a group for the first time.
A weekly task processes data for DevOps adoption.
This task is disabled until you access DevOps adoption for a group for the first time.
The data processing task updates the data on the first day of each month. If the monthly update
fails, the task tries daily until it succeeds.
The data processing task updates the data on the first day of each month.
If the monthly update fails, the task tries daily until it succeeds.
DevOps Adoption data may take up to a minute to appear while GitLab processes the group's data.
DevOps adoption data may take up to a minute to appear while GitLab processes the group's data.
## View feature adoption over time
## View DevOps adoption for groups
The **Adoption over time** chart shows the total number of adopted features from the previous
twelve months. The chart only shows data from when you enabled DevOps Adoption for the group.
Prerequisites:
To view feature adoption over time:
- You must have at least the Reporter role for the group.
To view DevOps adoption:
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Analyze > DevOps adoption**.
1. Select the **Overview** tab.
Tooltips display information about the features tracked for individual months.
The **Overview** tab displays the:
## Add or remove a subgroup
- Total number of features adopted.
- Features adopted in each category.
- Number of features adopted in each category by month in the **Adoption over time** chart.
The chart shows only data from the date you enabled DevOps adoption for the group.
- Number of features adopted in each category by subgroup in the **Adoption by subgroup** table.
To add or remove a subgroup from the DevOps Adoption report:
The **Dev**, **Sec**, and **Ops** tabs display the features adopted in development, security, and operations by subgroup.
1. Select **Add or remove subgroups**.
1. Select the subgroup you want to add or remove and select **Save changes**.
## Add a subgroup to DevOps adoption
It may take up to a minute for subgroup data to appear while GitLab collects the data.
Prerequisites:
- You must have at least the Reporter role for the group.
To add a subgroup to the DevOps adoption report:
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Analyze > DevOps adoption**.
1. From the **Add or remove subgroups** dropdown list, select the subgroup you want to add.
## Remove a subgroup from DevOps adoption
Prerequisites:
- You must have at least the Reporter role for the group.
To remove a subgroup from the DevOps adoption report:
1. On the left sidebar, select **Search or go to** and find your group.
1. Select **Analyze > DevOps adoption**.
1. Either:
- From the **Add or remove subgroups** dropdown list, clear the subgroup you want to remove.
- From the **Adoption by subgroup** table, in the row of the group you want to remove, select
**Remove Group from the table** (**{remove}**).

View File

@ -120,7 +120,7 @@ module Keeps
you must check the diff and pipeline failures to confirm if there are any issues.
It is the responsibility of the assignee (picked from ~"group::global search") to push those changes to this branch.
[Read more](https://docs.gitlab.com/ee/development/search/advanced_search_migration_styleguide.html#deleting-advanced-search-migrations-in-a-major-version-upgrade)
[Read more](https://docs.gitlab.com/ee/development/search/advanced_search_migration_styleguide.html#cleaning-up-advanced-search-migrations)
about the process for marking Advanced search migrations as obsolete.
All Advanced search migrations must have had at least one

View File

@ -57,7 +57,7 @@ module API
params = declared_params(include_missing: false)
result = ::Clusters::Agents::CreateService.new(user_project, current_user).execute(name: params[:name])
result = ::Clusters::Agents::CreateService.new(user_project, current_user, { name: params[:name] }).execute
bad_request!(result[:message]) if result[:status] == :error
@ -76,7 +76,11 @@ module API
agent = ::Clusters::AgentsFinder.new(user_project, current_user).find(params[:agent_id])
destroy_conditionally!(agent)
destroy_conditionally!(agent) do |agent|
::Clusters::Agents::DeleteService
.new(container: agent.project, current_user: current_user, params: { cluster_agent: agent })
.execute
end
end
end
end

View File

@ -1,7 +1,8 @@
# frozen_string_literal: true
require 'faraday'
require 'faraday_middleware'
require 'faraday/follow_redirects'
require 'faraday/retry'
require 'digest'
module ContainerRegistry
@ -15,7 +16,7 @@ module ContainerRegistry
ACCEPTED_TYPES = [DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE, OCI_MANIFEST_V1_TYPE].freeze
ACCEPTED_TYPES_RAW = [DOCKER_DISTRIBUTION_MANIFEST_V2_TYPE, OCI_MANIFEST_V1_TYPE, DOCKER_DISTRIBUTION_MANIFEST_LIST_V2_TYPE, OCI_DISTRIBUTION_INDEX_TYPE].freeze
RETRY_EXCEPTIONS = [Faraday::Request::Retry::DEFAULT_EXCEPTIONS, Faraday::ConnectionFailed].flatten.freeze
RETRY_EXCEPTIONS = [Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS, Faraday::ConnectionFailed].flatten.freeze
RETRY_OPTIONS = {
max: 1,
interval: 5,

View File

@ -20,7 +20,7 @@ module ContainerRegistry
limit: 3,
cookies: [],
callback: ->(response_env, request_env) do
request_env.request_headers.delete(::FaradayMiddleware::FollowRedirects::AUTH_HEADER)
request_env.request_headers.delete(::Faraday::FollowRedirects::Middleware::AUTH_HEADER)
redirect_to = request_env.url
unless redirect_to.scheme.in?(ALLOWED_REDIRECT_SCHEMES)
@ -161,7 +161,7 @@ module ContainerRegistry
@faraday_blob ||= faraday_base do |conn|
initialize_connection(conn, @options)
conn.use ::FaradayMiddleware::FollowRedirects, REDIRECT_OPTIONS
conn.use ::Faraday::FollowRedirects::Middleware, REDIRECT_OPTIONS
end
end
end

View File

@ -62,6 +62,13 @@ module Gitlab
attr_reader :path, :version
def content_result
context.logger.instrument(:config_component_fetch_content_hash) do
super
end
end
strong_memoize_attr :content_result
def component_result
::Ci::Components::FetchService.new(
address: location,

View File

@ -43,7 +43,7 @@ module Gitlab
return unless source_user
source_user.reassign_to_user || source_user.placeholder_user
source_user.accepted_reassign_to_user || source_user.placeholder_user
end
def create_source_user_mapping

View File

@ -113,12 +113,12 @@ module Gitlab
end
# highlighting is only performed by Elasticsearch backed results
def highlight_map(_scope)
def highlight_map(*)
{}
end
# aggregations are only performed by Elasticsearch backed results
def aggregations(_scope)
def aggregations(*)
[]
end

View File

@ -20774,9 +20774,15 @@ msgstr ""
msgid "Environment|Unauthorized to access %{resourceType} from this environment."
msgstr ""
msgid "Environment|Unavailable"
msgstr ""
msgid "Environment|Unhealthy"
msgstr ""
msgid "Environment|Unknown"
msgstr ""
msgid "Environment|You don't have permission to view all the namespaces in the cluster. If a namespace is not shown, you can still enter its name to select it."
msgstr ""
@ -23723,6 +23729,9 @@ msgstr ""
msgid "GitLab Duo Pro seats"
msgstr ""
msgid "GitLab Duo didn't find any reviewable files. Code Review request skipped."
msgstr ""
msgid "GitLab Enterprise Edition"
msgstr ""
@ -25450,9 +25459,6 @@ msgstr ""
msgid "GroupSettings|An experiment is a feature that is in the process of being developed. It is not production-ready. We encourage users to try experimental features and provide feedback. %{link_start}Learn more%{link_end}."
msgstr ""
msgid "GroupSettings|Analytics"
msgstr ""
msgid "GroupSettings|Analytics Dashboards"
msgstr ""
@ -25495,9 +25501,6 @@ msgstr ""
msgid "GroupSettings|Choose the merge request checks for projects in this group. This setting overrides the same settings configured on each project in this group."
msgstr ""
msgid "GroupSettings|Configure analytics features for this group."
msgstr ""
msgid "GroupSettings|Configure limits on the number of repositories users can download, clone, or fork in a given time."
msgstr ""
@ -41646,7 +41649,7 @@ msgstr ""
msgid "ProjectSettings|Build, test, and deploy your changes. Does not apply to project integrations."
msgstr ""
msgid "ProjectSettings|By default the current project is used."
msgid "ProjectSettings|Change the location of dashboards?"
msgstr ""
msgid "ProjectSettings|Checkbox is visible and selected by default."
@ -41691,9 +41694,6 @@ msgstr ""
msgid "ProjectSettings|Cube API URL"
msgstr ""
msgid "ProjectSettings|Custom dashboard projects"
msgstr ""
msgid "ProjectSettings|Data sources"
msgstr ""
@ -42003,7 +42003,7 @@ msgstr ""
msgid "ProjectSettings|Storage name:"
msgstr ""
msgid "ProjectSettings|Store configuration files for custom dashboards and visualizations."
msgid "ProjectSettings|Store configuration files for custom dashboards and visualizations. By default the current project is used."
msgstr ""
msgid "ProjectSettings|Submit changes to be merged upstream."

View File

@ -1,55 +0,0 @@
# frozen_string_literal: true
module Gitlab
module Page
module Admin
class Subscription < Chemlab::Page
path '/admin/subscription'
div :subscription_details
text_field :activation_code
button :activate
label :terms_of_services, text: /I agree that/
button :remove_license
button :confirm_remove_license
td :plan
td :name
td :company
td :email
h2 :users_in_subscription
table :subscription_history
div :no_valid_license_alert, text: /no longer has a valid license/
h3 :no_active_subscription_title, text: /do not have an active subscription/
def accept_terms
terms_of_services_element.click # workaround for hidden checkbox
end
def remove_license_file
remove_license
confirm_remove_license
end
# Checks if a subscription record exists in subscription history table
#
# @param plan [Hash] Name of the plan
# @option plan [Hash] Support::Helpers::FREE
# @option plan [Hash] Support::Helpers::PREMIUM_SELF_MANAGED
# @option plan [Hash] Support::Helpers::ULTIMATE_SELF_MANAGED
# @param users_in_license [Integer] Number of users in license
# @param license_type [Hash] Type of the license
# @option license_type [String] 'license file'
# @option license_type [String] 'cloud license'
# @return [Boolean] True if record exists, false if not
def has_subscription_record?(plan, users_in_license, license_type)
# find any records that have a matching plan and seats and type
subscription_history_element.hashes.any? do |record|
record['Plan'] == plan[:name].capitalize && record['Seats'] == users_in_license.to_s && \
record['Type'].strip.downcase == license_type
end
end
end
end
end
end

View File

@ -1,355 +0,0 @@
# frozen_string_literal: true
module Gitlab
module Page
module Admin
module Subscription
# @note Defined as +div :subscription_details+
# @return [String] The text content or value of +subscription_details+
def subscription_details
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription.subscription_details_element).to exist
# end
# @return [Watir::Div] The raw +Div+ element
def subscription_details_element
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription).to be_subscription_details
# end
# @return [Boolean] true if the +subscription_details+ element is present on the page
def subscription_details?
# This is a stub, used for indexing. The method is dynamically generated.
end
# @note Defined as +text_field :activation_code+
# @return [String] The text content or value of +activation_code+
def activation_code
# This is a stub, used for indexing. The method is dynamically generated.
end
# Set the value of activation_code
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# subscription.activation_code = 'value'
# end
# @param value [String] The value to set.
def activation_code=(value)
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription.activation_code_element).to exist
# end
# @return [Watir::TextField] The raw +TextField+ element
def activation_code_element
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription).to be_activation_code
# end
# @return [Boolean] true if the +activation_code+ element is present on the page
def activation_code?
# This is a stub, used for indexing. The method is dynamically generated.
end
# @note Defined as +button :activate+
# Clicks +activate+
def activate
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription.activate_element).to exist
# end
# @return [Watir::Button] The raw +Button+ element
def activate_element
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription).to be_activate
# end
# @return [Boolean] true if the +activate+ element is present on the page
def activate?
# This is a stub, used for indexing. The method is dynamically generated.
end
# @note Defined as +label :terms_of_services+
# @return [String] The text content or value of +terms_of_services+
def terms_of_services
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription.terms_of_services_element).to exist
# end
# @return [Watir::Label] The raw +Label+ element
def terms_of_services_element
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription).to be_terms_of_services
# end
# @return [Boolean] true if the +terms_of_services+ element is present on the page
def terms_of_services?
# This is a stub, used for indexing. The method is dynamically generated.
end
# @note Defined as +button :remove_license+
# Clicks +remove_license+
def remove_license
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription.remove_license_element).to exist
# end
# @return [Watir::Button] The raw +Button+ element
def remove_license_element
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription).to be_remove_license
# end
# @return [Boolean] true if the +remove_license+ element is present on the page
def remove_license?
# This is a stub, used for indexing. The method is dynamically generated.
end
# @note Defined as +button :confirm_remove_license+
# Clicks +confirm_remove_license+
def confirm_remove_license
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription.confirm_remove_license_element).to exist
# end
# @return [Watir::Button] The raw +Button+ element
def confirm_remove_license_element
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription).to be_confirm_remove_license
# end
# @return [Boolean] true if the +confirm_remove_license+ element is present on the page
def confirm_remove_license?
# This is a stub, used for indexing. The method is dynamically generated.
end
# @note Defined as +td :plan+
# @return [String] The text content or value of +plan+
def plan
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription.plan_element).to exist
# end
# @return [Watir::Td] The raw +Td+ element
def plan_element
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription).to be_plan
# end
# @return [Boolean] true if the +plan+ element is present on the page
def plan?
# This is a stub, used for indexing. The method is dynamically generated.
end
# @note Defined as +td :name+
# @return [String] The text content or value of +name+
def name
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription.name_element).to exist
# end
# @return [Watir::Td] The raw +Td+ element
def name_element
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription).to be_name
# end
# @return [Boolean] true if the +name+ element is present on the page
def name?
# This is a stub, used for indexing. The method is dynamically generated.
end
# @note Defined as +td :company+
# @return [String] The text content or value of +company+
def company
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription.company_element).to exist
# end
# @return [Watir::Td] The raw +Td+ element
def company_element
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription).to be_company
# end
# @return [Boolean] true if the +company+ element is present on the page
def company?
# This is a stub, used for indexing. The method is dynamically generated.
end
# @note Defined as +td :email+
# @return [String] The text content or value of +email+
def email
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription.email_element).to exist
# end
# @return [Watir::Td] The raw +Td+ element
def email_element
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription).to be_email
# end
# @return [Boolean] true if the +email+ element is present on the page
def email?
# This is a stub, used for indexing. The method is dynamically generated.
end
# @note Defined as +h2 :users_in_subscription+
# @return [String] The text content or value of +users_in_subscription+
def users_in_subscription
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription.users_in_subscription_element).to exist
# end
# @return [Watir::H2] The raw +H2+ element
def users_in_subscription_element
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription).to be_users_in_subscription
# end
# @return [Boolean] true if the +users_in_subscription+ element is present on the page
def users_in_subscription?
# This is a stub, used for indexing. The method is dynamically generated.
end
# @note Defined as +table :subscription_history+
# @return [String] The text content or value of +subscription_history+
def subscription_history
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription.subscription_history_element).to exist
# end
# @return [Watir::Table] The raw +Table+ element
def subscription_history_element
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription).to be_subscription_history
# end
# @return [Boolean] true if the +subscription_history+ element is present on the page
def subscription_history?
# This is a stub, used for indexing. The method is dynamically generated.
end
# @note Defined as +div :no_valid_license_alert+
# @return [String] The text content or value of +no_valid_license_alert+
def no_valid_license_alert
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription.no_valid_license_alert_element).to exist
# end
# @return [Watir::Div] The raw +Div+ element
def no_valid_license_alert_element
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription).to be_no_valid_license_alert
# end
# @return [Boolean] true if the +no_valid_license_alert+ element is present on the page
def no_valid_license_alert?
# This is a stub, used for indexing. The method is dynamically generated.
end
# @note Defined as +h3 :no_active_subscription_title+
# @return [String] The text content or value of +no_active_subscription_title+
def no_active_subscription_title
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription.no_active_subscription_title_element).to exist
# end
# @return [Watir::H3] The raw +H3+ element
def no_active_subscription_title_element
# This is a stub, used for indexing. The method is dynamically generated.
end
# @example
# Gitlab::Page::Admin::Subscription.perform do |subscription|
# expect(subscription).to be_no_active_subscription_title
# end
# @return [Boolean] true if the +no_active_subscription_title+ element is present on the page
def no_active_subscription_title?
# This is a stub, used for indexing. The method is dynamically generated.
end
end
end
end
end

View File

@ -102,7 +102,7 @@ class Cli
def proceed_to_metric_definition
new_page!
cli.say format_info("Amazing! The next step is adding a new metric! (~8 min)\n")
cli.say format_info("Amazing! The next step is adding a new metric! (~8-15 min)\n")
return not_ready_error('New Metric') unless cli.yes?(format_prompt('Ready to start?'))

View File

@ -23,7 +23,15 @@ module InternalEventsCli
introduced_by_url: 'TODO'
}.freeze
ExistingEvent = Struct.new(*NEW_EVENT_FIELDS, :file_path, keyword_init: true)
ExistingEvent = Struct.new(*NEW_EVENT_FIELDS, :file_path, keyword_init: true) do
def identifiers
self[:identifiers] || []
end
def available_filters
additional_properties&.keys || []
end
end
NewEvent = Struct.new(*NEW_EVENT_FIELDS, keyword_init: true) do
def formatted_output

View File

@ -17,6 +17,7 @@ module InternalEventsCli
include MetricOptions
MILESTONE = File.read('VERSION').strip.match(/(\d+\.\d+)/).captures.first
NAME_REGEX = /\A[a-z0-9_]+\z/
def new_page!(page = nil, total = nil, steps = [])
cli.say TTY::Cursor.clear_screen

View File

@ -17,14 +17,41 @@ module InternalEventsCli
end
end
def prompt_for_text(message, value = nil)
help_message = "(enter to #{value ? 'submit' : 'skip'})"
# Prompts the user to input text. Prefer this over calling cli#ask directly (so styling is consistent).
#
#
# @return [String, nil] user-provided text
# @param message [String] a single line prompt/question or last line of a prompt
# @param value [String, nil] prepopulated as the answer which user can accept/modify
# @option multiline [Boolean] indicates that any help text or prompt prefix will be printed on another line
# before calling #prompt_for_text --> ex) see MetricDefiner#prompt_for_description
# @yield [TTY::Prompt::Question]
# @see https://github.com/piotrmurach/tty-prompt?tab=readme-ov-file#21-ask
def prompt_for_text(message, value = nil, multiline: false, **opts)
prompt = message.dup # mutable for concat in #ask callback
cli.ask(
"#{message} #{format_help(help_message)}",
value: value || '',
**input_opts
)
options = { **input_opts, **opts }
value ||= options.delete(:value)
options.delete(:prefix) if multiline
cli.ask(prompt, **options) do |q|
q.value(value) if value
yield q if block_given?
if multiline
# wrap error messages so they render nicely with prompt
q.messages.each do |key, error|
closing_text = "\n#{format_error('<<|')}" if error.lines.length > 1
q.messages[key] = [error, closing_text, "\n\n\n"].join('')
end
else
# append help text only if this line includes the formatted 'prompt' prefix,
# otherwise depend on the caller to print the help text if needed
prompt.concat(" #{q.required ? input_required_text : input_optional_text(value)}")
end
end
end
def input_opts
@ -35,12 +62,20 @@ module InternalEventsCli
{ prefix: format_prompt('Yes/No: ') }
end
# Provide to cli#select as kwargs for consistent style/ux
def select_opts
{ prefix: format_prompt('Select one: '), cycle: true, show_help: :always }
{
prefix: format_prompt('Select one: '),
cycle: true,
show_help: :always,
# Strip colors so #format_selection is applied uniformly
active_color: ->(choice) { format_selection(clear_format(choice)) }
}
end
# Provide to cli#multiselect as kwargs for consistent style/ux
def multiselect_opts
{ prefix: format_prompt('Select multiple: '), cycle: true, show_help: :always, min: 1 }
{ **select_opts, prefix: format_prompt('Select multiple: '), min: 1 }
end
# Accepts a number of lines occupied by text, so remaining
@ -52,10 +87,18 @@ module InternalEventsCli
}
end
# Help text to use with required, multiline cli#ask prompts.
# Otherwise, prefer #prompt_for_text.
def input_required_text
format_help("(leave blank for help)")
end
# Help text to use with optional, multiline cli#ask prompts.
# Otherwise, prefer #prompt_for_text.
def input_optional_text(value)
format_help("(enter to #{value ? 'submit' : 'skip'})")
end
def disableable_option(value:, disabled:, name: nil)
should_disable = yield
name ||= value

View File

@ -4,6 +4,8 @@
module InternalEventsCli
module Helpers
module Files
MAX_FILENAME_LENGTH = 100
def prompt_to_save_file(filepath, content)
cli.say <<~TEXT.chomp
#{format_info('Preparing to generate definition with these attributes:')}

View File

@ -31,10 +31,33 @@ module InternalEventsCli
pastel.red(string)
end
# Strips all existing color/text style
def clear_format(string)
pastel.strip(string)
end
def format_heading(string)
[divider, pastel.cyan(string), divider].join("\n")
end
# Used for grouping prompts that occur on the same screen
# or as part of the same step of a flow.
#
# Counter is exluded if total is 1.
# The subject's formatting is extended to the counter.
#
# @return [String] ex) -- EATING COOKIES (2/3): Chocolate Chip --
# @param subject [String] describes task generically ex) EATING COOKIES
# @param item [String] describes specific context ex) Chocolate Chip
# @param count [Integer] ex) 2
# @param total [Integer] ex) 3
def format_subheader(subject, item, count, total)
formatting_end = "\e[0m"
suffix = formatting_end if subject[-formatting_end.length..] == formatting_end
"-- #{[subject.chomp(formatting_end), counter(count, total)].compact.join(' ')}:#{suffix} #{item} --"
end
def format_prefix(prefix, string)
string.lines.map { |line| line.prepend(prefix) }.join
end
@ -59,8 +82,11 @@ module InternalEventsCli
"#{status}\n|==#{complete}>#{incomplete}|\n"
end
# Formats a counter if there's anything to count
#
# @return [String, nil] ex) "(3/4)""
def counter(idx, total)
format_prompt("(#{idx + 1}/#{total})") if total > 1
"(#{idx + 1}/#{total})" if total > 1
end
private

View File

@ -11,24 +11,38 @@ module InternalEventsCli
nil => "%s occurrences"
}.freeze
# Creates a list of metrics to be used as options in a
# select/multiselect menu; existing metrics and metrics for
# unavailable identifiers are marked as disabled
#
# @param events [Array<ExistingEvent>]
# @return [Array<Hash>] hash (compact) has keys/values:
# value: [Array<NewMetric>]
# name: [String] formatted description of the metrics
# disabled: [String] reason metrics are disabled
def get_metric_options(events)
actions = events.map(&:action)
options = get_all_metric_options(actions)
identifiers = get_identifiers_for_events(events)
metric_name = format_metric_name_for_events(events)
filter_name = format_filter_options_for_events(events)
options.reject!(&:filters_expected?) unless filter_name
options = options.group_by do |metric|
[
metric.identifier.value,
conflicting_metric_exists?(metric),
metric.filters_expected?,
metric.time_frame.value == 'all'
]
end
options.map do |(identifier, defined, _), metrics|
options.map do |(identifier, defined, filtered, _), metrics|
format_metric_option(
identifier,
metric_name,
(filter_name if filtered),
metrics,
defined: defined,
supported: [*identifiers, nil].include?(identifier)
@ -38,50 +52,103 @@ module InternalEventsCli
private
# Lists all potential metrics supported in service ping,
# ordered by: identifier > filters > time_frame
#
# @param actions [Array<String>] event names
# @return [Array<NewMetric>]
def get_all_metric_options(actions)
[
Metric.new(actions: actions, time_frame: '28d', identifier: 'user'),
Metric.new(actions: actions, time_frame: '7d', identifier: 'user'),
Metric.new(actions: actions, time_frame: '28d', identifier: 'user', filters: []),
Metric.new(actions: actions, time_frame: '7d', identifier: 'user', filters: []),
Metric.new(actions: actions, time_frame: '28d', identifier: 'project'),
Metric.new(actions: actions, time_frame: '7d', identifier: 'project'),
Metric.new(actions: actions, time_frame: '28d', identifier: 'project', filters: []),
Metric.new(actions: actions, time_frame: '7d', identifier: 'project', filters: []),
Metric.new(actions: actions, time_frame: '28d', identifier: 'namespace'),
Metric.new(actions: actions, time_frame: '7d', identifier: 'namespace'),
Metric.new(actions: actions, time_frame: '28d', identifier: 'namespace', filters: []),
Metric.new(actions: actions, time_frame: '7d', identifier: 'namespace', filters: []),
Metric.new(actions: actions, time_frame: '28d'),
Metric.new(actions: actions, time_frame: '7d'),
Metric.new(actions: actions, time_frame: 'all')
Metric.new(actions: actions, time_frame: '28d', filters: []),
Metric.new(actions: actions, time_frame: '7d', filters: []),
Metric.new(actions: actions, time_frame: 'all'),
Metric.new(actions: actions, time_frame: 'all', filters: [])
]
end
# Very brief summary of the provided events to use in a basic
# description of the metric; does not account for filters
#
# @param events [Array<ExistingEvent>]
# @return [String]
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(&:&) || []
# Formats the list of the additional properties available
# across any of the events
#
# @param events [Array<ExistingEvent>]
# @return [String] ex) "label/property"
def format_filter_options_for_events(events)
available_filters = events.flat_map(&:available_filters).uniq
available_filters.join('/') if available_filters.any?
end
# Get only the identifiers in common for all events
#
# @param events [Array<ExistingEvent>]
# @return [Array<String>]
def get_identifiers_for_events(events)
events.map(&:identifiers).reduce(&:&)
end
# Checks if there's an existing metric which has the same
# properties as the new one
#
# @param new_metric [NewMetric]
# @return [Boolean]
def conflicting_metric_exists?(new_metric)
# metrics with filters are conflict-free until new filters are defined
return false if new_metric.filters_expected?
cli.global.metrics.any? do |existing_metric|
existing_metric.actions == new_metric.actions &&
existing_metric.time_frame == new_metric.time_frame.value &&
existing_metric.identifier == new_metric.identifier.value
existing_metric.identifier == new_metric.identifier.value &&
!existing_metric.filtered?
end
end
def format_metric_option(identifier, event_name, metrics, defined:, supported:)
# Formats & assembles a single select/multiselect menu item,
#
# @param identifier [String] user/project/namespace (must support unique metrics)
# @param event_name [String]
# @param filter_name [String]
# @param metrics [Array<NewMetric>]
# @option defined [Boolean]
# @option supported [Boolean]
# @return [Hash] see #get_metric_options for format
def format_metric_option(identifier, event_name, filter_name, metrics, defined:, supported:)
time_frame = metrics.map { |metric| metric.time_frame.description }.join('/')
unique_by = "unique #{identifier}s " if identifier
event_phrase = EVENT_PHRASES[identifier] % event_name
filter_phrase = " where filtered" if filter_name
if supported && !defined
filter_phrase = " #{format_info('where')} #{filter_name} is..." if filter_name
time_frame = format_info(time_frame)
unique_by = format_info(unique_by)
end
name = "#{time_frame} count of #{unique_by}[#{event_phrase}]"
name = "#{time_frame} count of #{unique_by}[#{event_phrase}]#{filter_phrase}"
if supported && defined
disabled = format_warning("(already defined)")

View File

@ -48,12 +48,22 @@ module InternalEventsCli
events&.map { |event| event['name'] } # rubocop:disable Rails/Pluck -- not rails
end
def filters
events&.map do |event|
[event['name'], event['filter'] || {}]
end
end
def filtered?
!!filters&.any? { |(_action, filter)| filter&.any? }
end
def time_frame
self[:time_frame] || 'all'
end
end
NewMetric = Struct.new(*NEW_METRIC_FIELDS, :identifier, :actions, :key, keyword_init: true) do
NewMetric = Struct.new(*NEW_METRIC_FIELDS, :identifier, :actions, :key, :filters, keyword_init: true) do
def formatted_output
METRIC_DEFAULTS
.merge(to_h.compact)
@ -101,13 +111,26 @@ module InternalEventsCli
Metric::Key.new(self[:key] || actions, time_frame, identifier)
end
def events
self[:events] || actions.map { |action| event_params(action) }
def filters
Metric::Filters.new(self[:filters])
end
def event_params(action)
# Returns value for the `events` key in the metric definition.
# Requires #actions or #filters to be set by the caller first.
#
# @return [Hash]
def events
if filters.assigned?
self[:filters].map { |(action, filter)| event_params(action, filter) }
else
actions.map { |action| event_params(action) }
end
end
def event_params(action, filter = nil)
params = { 'name' => action }
params['unique'] = "#{identifier.value}.id" if identifier.value
params['filter'] = filter if filter&.any?
params
end
@ -116,20 +139,39 @@ module InternalEventsCli
self[:actions] || []
end
# How to interpretting different values for filters:
# nil --> not expected, assigned or filtered
# (metric not initialized with filters)
# [] --> both expected and filtered
# (metric initialized with filters, but not yet assigned by user)
# [['event', {}]] --> not expected, assigned or filtered
# (filters were expected, but then skipped by user)
# [['event', { 'label' => 'a' }]] --> both assigned and filtered
# (filters exist for any event; user is done assigning)
def filtered?
filters.assigned? || filters.expected?
end
def filters_expected?
filters.expected?
end
def description_prefix
[time_frame.description, identifier.description].join(' ')
end
# Provides simplified but technically accurate description
def technical_description
simple_event_list = actions.join(' or ')
event_name = actions.first if events.length == 1 && !filtered?
event_name ||= 'the selected events'
case identifier
case identifier.value
when 'user'
"#{description_prefix} who triggered #{simple_event_list}"
"#{description_prefix} who triggered #{event_name}"
when 'project', 'namespace'
"#{description_prefix} where #{simple_event_list} occurred"
"#{description_prefix} where #{event_name} occurred"
else
"#{description_prefix} #{simple_event_list} occurrences"
"#{description_prefix} #{event_name} occurrences"
end
end
@ -181,11 +223,13 @@ module InternalEventsCli
end
Key = Struct.new(:events, :time_frame, :identifier) do
def value
# @param name_to_display [String] return the key with the
# provided name instead of a list of event names
def value(name_to_display = nil)
[
'count',
identifier&.key_path,
name_for_events,
name_to_display || name_for_events,
time_frame&.key_path
].compact.join('_')
end
@ -194,7 +238,13 @@ module InternalEventsCli
"#{prefix}.#{value}"
end
# Refers to the middle portion of a metric's `key_path`
# pertaining to the relevent events; This does not include
# identifier/time_frame/etc
def name_for_events
# user may have defined a different name for events
return events unless events.respond_to?(:join)
events.join('_and_')
end
@ -207,6 +257,28 @@ module InternalEventsCli
end
end
Filters = Struct.new(:filters) do
def expected?
filters == []
end
def assigned?
!!filters&.any? { |(_action, filter)| filter.any? }
end
def descriptions
Array(filters).filter_map do |(action, filter)|
next action if filter.none?
"#{action}(#{describe_filter(filter)})"
end.sort_by(&:length)
end
def describe_filter(filter)
filter.map { |k, v| "#{k}=#{v}" }.join(',')
end
end
def self.parse(**args)
ExistingMetric.new(**args)
end

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true
require_relative './helpers'
require_relative './text'
module InternalEventsCli
class MetricDefiner
@ -20,12 +21,28 @@ module InternalEventsCli
'Save files'
].freeze
NAME_REQUIREMENT_REASONS = {
filters: {
text: 'Metrics using filters are too complex for default naming.',
help: Text::METRIC_NAME_FILTER_HELP
},
length: {
text: 'The default filename will be too long.',
help: Text::METRIC_NAME_LENGTH_HELP
},
conflict: {
text: 'The default key path is already in use.',
help: Text::METRIC_NAME_CONFLICT_HELP
}
}.freeze
attr_reader :cli
def initialize(cli, starting_event = nil)
@cli = cli
@selected_event_paths = Array(starting_event)
@metrics = []
@selected_filters = {}
end
def run
@ -35,10 +52,11 @@ module InternalEventsCli
return unless @selected_event_paths.any?
prompt_for_metrics
prompt_for_event_filters
return unless @metrics.any?
prompt_for_description
prompt_for_descriptions
defaults = prompt_for_copying_event_properties
prompt_for_product_group(defaults)
prompt_for_url(defaults)
@ -49,6 +67,8 @@ module InternalEventsCli
private
# ----- Memoization Helpers -----------------
def events
@events ||= events_by_filepath(@selected_event_paths)
end
@ -57,6 +77,8 @@ module InternalEventsCli
@selected_events ||= events.values_at(*@selected_event_paths)
end
# ----- Prompts -----------------------------
def prompt_for_metric_type
return if @selected_event_paths.any?
@ -112,8 +134,15 @@ module InternalEventsCli
end
new_page!(3, 9, STEPS)
cli.say format_info('SELECTED EVENTS')
cli.say selected_events_filter_options.join
cli.say "\n"
@metrics = cli.select('Which metrics do you want to add?', eligible_metrics, **select_opts)
@metrics = cli.select(
'Which metrics do you want to add?',
eligible_metrics,
**select_opts,
per_page: 20)
assign_shared_attrs(:actions, :milestone) do
{
@ -123,50 +152,70 @@ module InternalEventsCli
end
end
def prompt_for_description
new_page!(4, 9, STEPS)
def prompt_for_event_filters
return if @metrics.none?(&:filters_expected?)
cli.say Text::METRIC_DESCRIPTION_INTRO
cli.say selected_event_descriptions.join('')
event_count = selected_events.length
previous_inputs = {
'label' => nil,
'property' => nil,
'value' => nil
}
base_description = nil
event_filters = selected_events.dup.flat_map.with_index do |event, idx|
print_event_filter_header(event, idx, event_count)
@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(' ')
next if deselect_nonfilterable_event?(event) # prompts user
last_line_of_prompt = "\n Finish the description: #{format_info("#{metric.description_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
filter_values = event.additional_properties&.filter_map do |property, _|
prompt_for_property_filter(
event.action,
property,
previous_inputs[property]
)
end
cli.say("\n") # looks like multiline input, but isn't. Spacer improves clarity.
previous_inputs.merge!(@selected_filters[event.action] || {})
metric.description = "#{metric.description_prefix} #{base_description}"
end
find_filter_permutations(event.action, filter_values)
end.compact
bulk_assign(filters: event_filters)
end
def selected_event_descriptions
@selected_event_descriptions ||= selected_events.map do |event|
" #{event.action} - #{format_selection(event.description)}\n"
def prompt_for_descriptions
default_description = nil
default_key = nil
separate_page_per_metric = @metrics.any? do |metric|
name_requirement_reason(metric)
end
@metrics.each_with_index do |metric, idx|
if idx == 0 || separate_page_per_metric
new_page!(4, 9, STEPS)
cli.say Text::METRIC_DESCRIPTION_INTRO
cli.say selected_event_descriptions.join
end
cli.say "\n"
cli.say format_prompt(format_subheader(
'DESCRIBING METRIC',
metric.technical_description,
idx,
@metrics.length
))
prompt_for_description(metric, default_description).tap do |description|
default_description = description
metric.description = "#{metric.description_prefix} #{description}"
end
prompt_for_metric_name(metric, default_key)&.tap do |key|
default_key = key
metric.key = key
end
end
end
@ -198,26 +247,6 @@ module InternalEventsCli
shared_values
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_group] << event.product_group
fields[:stage] << find_stage(event.product_group)
fields[:section] << find_section(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_group(defaults)
assign_shared_attr(:product_group) do
new_page!(6, 9, STEPS)
@ -231,7 +260,7 @@ module InternalEventsCli
new_page!(7, 9, STEPS)
prompt_for_text(
"Which MR URL introduced the metric?",
'Which MR URL introduced the metric?',
defaults[:introduced_by_url]
)
end
@ -257,7 +286,8 @@ module InternalEventsCli
@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")
cli.say format_prompt(format_subheader('SAVING FILE', metric.description, idx, @metrics.length))
cli.say "\n"
prompt_to_save_file(metric.file_path, metric.formatted_output)
end
@ -304,6 +334,193 @@ module InternalEventsCli
end
end
# ----- Prompt-specific Helpers -------------
# Helper for #prompt_for_metrics
def selected_events_filter_options
filterable_events_selected = selected_events.any? { |event| event.additional_properties&.any? }
selected_events.map do |event|
filters = event.additional_properties&.keys
filter_phrase = if filters
" (filterable by #{filters&.join(', ')})"
elsif filterable_events_selected
' -- not filterable'
end
" - #{event.action}#{format_help(filter_phrase)}\n"
end
end
# Helper for #prompt_for_event_filters
def print_event_filter_header(event, idx, total)
cli.say "\n"
cli.say format_info(format_subheader('SETTING EVENT FILTERS', event.action, idx, total))
return unless event.additional_properties&.any?
event_filter_options = event.additional_properties.map do |property, attrs|
" #{property}: #{attrs['description']}\n"
end
cli.say event_filter_options.join
end
# Helper for #prompt_for_event_filters
def deselect_nonfilterable_event?(event)
cli.say "\n"
return false if event.additional_properties&.any?
return false if cli.yes?("This event is not filterable. Should it be included in the metric?", **yes_no_opts)
selected_events.delete(event)
bulk_assign(actions: selected_events.map(&:action).sort)
true
end
# Helper for #prompt_for_event_filters
def prompt_for_property_filter(action, property, default)
formatted_prop = format_info(property)
prompt = "Count where #{formatted_prop} equals any of (comma-sep):"
inputs = prompt_for_text(prompt, default, **input_opts) do |q|
if property == 'value'
q.convert ->(input) { input.split(',').map(&:to_i).uniq }
q.validate %r{^(\d|\s|,)*$}
q.messages[:valid?] = "Inputs for #{formatted_prop} must be numeric"
else
q.convert ->(input) { input.split(',').map(&:strip).uniq }
end
end
return unless inputs&.any?
@selected_filters[action] ||= {}
@selected_filters[action][property] = inputs.join(',')
inputs.map { |input| { property => input } }.uniq
end
# Helper for #prompt_for_event_filters
#
# Gets all the permutations of the provided property values.
# @param filters [Array] ex) [{ 'label' => 'red' }, { 'label' => 'blue' }, { value => 16 }]
# @return ex) [{ 'label' => 'red', value => 16 }, { 'label' => 'blue', value => 16 }]
def find_filter_permutations(action, filters)
# Define a filter for all events, regardless of the available props so NewMetric#events is correct
return [[action, {}]] unless filters&.any?
# Uses proc syntax to avoid spliting & type-checking `filters`
:product.to_proc.call(*filters).map do |filter|
[action, filter.reduce(&:merge)]
end
end
# Helper for #prompt_for_descriptions
def selected_event_descriptions
selected_events.map do |event|
filters = @selected_filters[event.action]
if filters&.any?
filter_phrase = filters.map { |k, v| "#{k}=#{v}" }.join(' ')
filter_phrase = format_help("(#{filter_phrase})")
end
" #{event.action}#{filter_phrase} - #{format_selection(event.description)}\n"
end
end
# Helper for #prompt_for_descriptions
def prompt_for_description(metric, default)
description_start = format_info("#{metric.description_prefix}...")
cli.say <<~TEXT
#{input_opts[:prefix]} How would you describe this metric to a non-technical person? #{input_required_text}
TEXT
prompt_for_text(" Finish the description: #{description_start}", default, multiline: true) do |q|
q.required true
q.messages[:required?] = Text::METRIC_DESCRIPTION_HELP
end
end
# Helper for #prompt_for_descriptions
def prompt_for_metric_name(metric, default)
name_reason = name_requirement_reason(metric)
default_name = metric.key.value
display_name = metric.key.value("\e[0m[REPLACE ME]\e[36m")
empty_name = metric.key.value('')
return unless name_reason
cli.say <<~TEXT
#{input_opts[:prefix]} #{name_reason[:text]} How should we refererence this metric? #{input_required_text}
ID: #{format_info(display_name)}
Filename: #{format_info(display_name)}#{format_info('.yml')}
TEXT
max_length = MAX_FILENAME_LENGTH - "#{empty_name}.yml".length
help_tokens = { name: default_name, count: max_length }
prompt_for_text(' Replace with: ', default, multiline: true) do |q|
q.required true
q.messages[:required?] = name_reason[:help] % help_tokens
q.messages[:valid?] = Text::METRIC_NAME_ERROR % help_tokens
q.validate ->(input) do
input.length <= max_length &&
input.match?(NAME_REGEX) &&
!conflicting_key_path?(metric.key.value(input))
end
end
end
# Helper for #prompt_for_descriptions
def name_requirement_reason(metric)
if metric.filters.assigned?
NAME_REQUIREMENT_REASONS[:filters]
elsif metric.file_name.length > MAX_FILENAME_LENGTH
NAME_REQUIREMENT_REASONS[:length]
elsif conflicting_key_path?(metric.key_path)
NAME_REQUIREMENT_REASONS[:conflict]
end
end
# Helper for #prompt_for_descriptions
def conflicting_key_path?(key_path)
cli.global.metrics.any? do |existing_metric|
existing_metric.key_path == key_path
end
end
# Helper for #prompt_for_copying_event_properties
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_group] << event.product_group
fields[:stage] << find_stage(event.product_group)
fields[:section] << find_section(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
# ----- Shared Helpers ----------------------
def assign_shared_attrs(...)
metric = @metrics.first
attrs = metric.to_h.slice(...)

View File

@ -218,7 +218,7 @@ module InternalEventsCli
#{format_info('SELECTED EVENT(S):')}
TEXT
METRIC_DESCRIPTION_HELP = <<~TEXT.freeze
METRIC_DESCRIPTION_HELP = <<~TEXT.chomp.freeze
#{format_warning('Required. 10+ words likely, but length may vary.')}
An event description can often be rearranged to work as a metric description.
@ -229,5 +229,31 @@ module InternalEventsCli
Look at the event descriptions above to get ideas!
TEXT
METRIC_NAME_FILTER_HELP = <<~TEXT.freeze
#{format_warning('Required. Max %{count} characters. Only lowercase/numbers/underscores allowed.')}
Metrics with filters must manually define this portion of their key path.
Auto-generated key paths for metrics filters results in long & confusing naming. By defining them manually, clarity and discoverability should be better.
TEXT
METRIC_NAME_CONFLICT_HELP = <<~TEXT.freeze
#{format_warning('Required. Max %{count} characters. Only lowercase/numbers/underscores allowed.')}
Conflict! A metric with the same name already exists: %{name}
TEXT
METRIC_NAME_LENGTH_HELP = <<~TEXT.freeze
#{format_warning('Required. Max %{count} characters. Only lowercase/numbers/underscores allowed.')}
Filenames cannot exceed 100 characters. The key path (ID) is not restricted, but keeping them aligned is recommended.
If needed, you can modify the key path and filename further after saving.
TEXT
METRIC_NAME_ERROR = <<~TEXT.freeze
#{format_warning('Input is invalid. Max %{count} characters. Only lowercase/numbers/underscores allowed. Ensure this key path (ID) is not already in use.')}
TEXT
end
end

View File

@ -14,6 +14,7 @@ FactoryBot.define do
end
trait :with_reassign_to_user do
with_placeholder_user
reassign_to_user factory: :user
end

View File

@ -59,6 +59,8 @@ RSpec.describe 'Work item children', :js, feature_category: :team_planning do
end
it 'adds a new child task', :aggregate_failures do
allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(104)
within_testid('work-item-links') do
click_button 'Add'
click_button 'New task'
@ -77,6 +79,7 @@ RSpec.describe 'Work item children', :js, feature_category: :team_planning do
end
it 'removes a child task and undoing', :aggregate_failures do
allow(Gitlab::QueryLimiting::Transaction).to receive(:threshold).and_return(104)
within_testid('work-item-links') do
click_button 'Add'
click_button 'New task'

View File

@ -0,0 +1,23 @@
---
description: Internal Event CLI is opened
internal_events: true
action: internal_events_cli_opened
identifiers:
- project
- namespace
- user
additional_properties:
label:
description: TODO
value:
description: Time the CLI ran before closing (seconds)
product_group: analytics_instrumentation
milestone: '16.6'
introduced_by_url: TODO
distributions:
- ce
- ee
tiers:
- free
- premium
- ultimate

View File

@ -0,0 +1,25 @@
---
key_path: counts.count_total_failed_usage_attempts
description: Total count of when someone tried and failed to define an internal event using the CLI
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
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate
events:
- name: internal_events_cli_closed
- name: internal_events_cli_used
filter:
label: failure
property: events

View File

@ -0,0 +1,24 @@
---
key_path: counts.count_total_cli_interactions
description: Total count of CLI interations
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
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate
events:
- name: internal_events_cli_closed
- name: internal_events_cli_opened
- name: internal_events_cli_used
- name: random_name

View File

@ -0,0 +1,28 @@
---
key_path: redis_hll_counters.count_distinct_user_id_from_failed_usage_attempts_monthly
description: Monthly count of unique users who tried and failed to define an internal event using the CLI
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
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate
events:
- name: internal_events_cli_used
unique: user.id
filter:
label: failure
- name: internal_events_cli_used
unique: user.id
filter:
label: incomplete

View File

@ -0,0 +1,44 @@
---
key_path: redis_hll_counters.count_distinct_user_id_from_failed_usage_attempts_under_60s_monthly
description: Monthly count of unique users who tried and failed to define an internal event using the CLI
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
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate
events:
- name: internal_events_cli_used
unique: user.id
filter:
label: failure
property: metrics
value: 60
- name: internal_events_cli_used
unique: user.id
filter:
label: failure
property: events
value: 60
- name: internal_events_cli_used
unique: user.id
filter:
label: incomplete
property: metrics
value: 60
- name: internal_events_cli_used
unique: user.id
filter:
label: incomplete
property: events
value: 60

View File

@ -0,0 +1,28 @@
---
key_path: redis_hll_counters.count_distinct_user_id_from_failed_usage_attempts_weekly
description: Weekly count of unique users who tried and failed to define an internal event using the CLI
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
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate
events:
- name: internal_events_cli_used
unique: user.id
filter:
label: failure
- name: internal_events_cli_used
unique: user.id
filter:
label: incomplete

View File

@ -0,0 +1,44 @@
---
key_path: redis_hll_counters.count_distinct_user_id_from_failed_usage_attempts_under_60s_weekly
description: Weekly count of unique users who tried and failed to define an internal event using the CLI
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
distribution:
- ce
- ee
tier:
- free
- premium
- ultimate
events:
- name: internal_events_cli_used
unique: user.id
filter:
label: failure
property: metrics
value: 60
- name: internal_events_cli_used
unique: user.id
filter:
label: failure
property: events
value: 60
- name: internal_events_cli_used
unique: user.id
filter:
label: incomplete
property: metrics
value: 60
- name: internal_events_cli_used
unique: user.id
filter:
label: incomplete
property: events
value: 60

View File

@ -304,3 +304,128 @@
- "1\n" # Select: Copy & continue
- "n\n" # Don't overwrite file
- "5\n" # Exit
- description: Create weekly/monthly metrics for a single event, filtering on multiple values for one property and skipping another property completely
inputs:
files:
- path: config/events/internal_events_cli_used.yml
content: spec/fixtures/scripts/internal_events/events/event_with_additional_properties.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[B" # Arrow down to: Weekly count of unique users where label/value is...
- "\n" # Select: Weekly count of unique users where label/value is...
- "failure, incomplete\n" # Input multiple values for "label" filter
- "\n" # Skip "value" filter
- "who tried and failed to define an internal event using the CLI\n" # Input description
- "failed_usage_attempts\n" # Input metric key path
- "\n" # Submit monthly description for weekly
- "\n" # Submit monthly name for weekly
- "1\n" # Enum-select: Copy & continue
- "y\n" # Create file
- "y\n" # Create file
- "5\n" # Exit
outputs:
files:
- path: config/metrics/counts_28d/count_distinct_user_id_from_failed_usage_attempts_monthly.yml
content: spec/fixtures/scripts/internal_events/metrics/user_id_28d_single_event_additional_props.yml
- path: config/metrics/counts_7d/count_distinct_user_id_from_failed_usage_attempts_weekly.yml
content: spec/fixtures/scripts/internal_events/metrics/user_id_7d_single_event_additional_props.yml
- description: Create a weekly/monthly metric for a single event with all additional properties
inputs:
files:
- path: config/events/internal_events_cli_used.yml
content: spec/fixtures/scripts/internal_events/events/event_with_all_additional_properties.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[B" # Arrow down to: Weekly count of unique users where label/value is...
- "\n" # Select: Weekly count of unique users where label/value is...
- "failure, incomplete\n" # Input multiple values for "label" filter
- "metrics, events\n" # Input multiple values for "property" filter
- "bad input, 16\n" # Fail "value" validation
- "60\n" # Input valid "value"
- "who tried and failed to define an internal event using the CLI\n" # Input description
- "failed_usage_attempts_under_60s\n" # Input metric key path
- "\n" # Submit weekly description for monthly
- "\n" # Submit weekly name for monthly
- "1\n" # Enum-select: Copy & continue
- "y\n" # Create file
- "y\n" # Create file
- "5\n" # Exit
outputs:
files:
- path: config/metrics/counts_28d/count_distinct_user_id_from_failed_usage_attempts_under_60s_monthly.yml
content: spec/fixtures/scripts/internal_events/metrics/user_id_28d_single_event_all_additional_props.yml
- path: config/metrics/counts_7d/count_distinct_user_id_from_failed_usage_attempts_under_60s_weekly.yml
content: spec/fixtures/scripts/internal_events/metrics/user_id_7d_single_event_all_additional_props.yml
- description: Create a weekly/monthly metric for multiple events with and without additional properties
inputs:
files:
- path: config/events/internal_events_cli_used.yml
content: spec/fixtures/scripts/internal_events/events/event_with_all_additional_properties.yml
- path: config/events/internal_events_cli_closed.yml
content: spec/fixtures/scripts/internal_events/events/secondary_event_with_identifiers.yml
- path: config/events/internal_events_cli_opened.yml
content: spec/fixtures/scripts/internal_events/events/ee_event_without_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 " # Multi-select: Arrow down and select internal_events_cli_opened
- "\e[B " # Multi-select: Arrow down and select internal_events_cli_used
- "\n" # Submit selections
- "\e[A\n" # Multi-select: Arrow up and select Total count of events where label/property/value is...
- "\n" # Select Yes: include internal_events_cli_closed in the metric anyways (no additional properties)
- "n\n" # Select No: remove internal_events_cli_opened from the metric (no additional properties)
- "failure\n" # Input value for "label" filter
- "events\n" # Input value for "property" filter
- "\n" # Skip "value" filter
- "when someone tried and failed to define an internal event using the CLI\n" # Input description
- "failed_usage_attempts\n" # Input metric key path
- "\n" # Submit description
- "1\n" # Enum-select: Copy & continue
- "y\n" # Create file
- "5\n" # Exit
outputs:
files:
- path: config/metrics/counts_all/count_total_failed_usage_attempts.yml
content: spec/fixtures/scripts/internal_events/metrics/total_multi_event_some_additional_props.yml
- description: Creates metric under a user-provided name if the default is too long
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
- path: config/events/internal_events_cli_opened.yml
content: spec/fixtures/scripts/internal_events/events/secondary_event_with_additional_properties.yml
- path: config/events/random_name.yml
content: spec/fixtures/scripts/internal_events/events/keyboard_smashed_event.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
- "_events_cli" # Filter list to just our CLI events
- "\e[B \e[B \e[B " # Select all 3 internal_events_cli events
- "\u007F\u007F\u007F\u007F\u007F\u007F\u007F\u007F\u007F\u007F\u007F" # Clear filter
- "random_name " # Filter list to the last event & select
- "\n" # Submit selections
- "\e[A\e[A\n" # Arrow up & Select: Total count of events (no filters)
- "CLI interations\n" # Input description
- "cli_interactions\n" # Input new metric name
- "1\n" # Enum-select: Copy & continue
- "instrumentation\n" # Select the analytics instrumentation group
- "y\n" # Create file
- "5\n" # Exit
outputs:
files:
- path: config/metrics/counts_all/count_total_cli_interactions.yml
content: spec/fixtures/scripts/internal_events/metrics/total_multiple_events_with_rename.yml

View File

@ -1,14 +1,16 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { GlEmptyState, GlAlert } from '@gitlab/ui';
import { GlEmptyState, GlAlert, GlDrawer } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
import WorkloadDetails from '~/kubernetes_dashboard/components/workload_details.vue';
import KubernetesOverview from '~/environments/environment_details/components/kubernetes/kubernetes_overview.vue';
import KubernetesStatusBar from '~/environments/environment_details/components/kubernetes/kubernetes_status_bar.vue';
import KubernetesAgentInfo from '~/environments/environment_details/components/kubernetes/kubernetes_agent_info.vue';
import KubernetesTabs from '~/environments/environment_details/components/kubernetes/kubernetes_tabs.vue';
import { k8sResourceType } from '~/environments/graphql/resolvers/kubernetes/constants';
import { mockPodsTableItems } from 'jest/kubernetes_dashboard/graphql/mock_data';
import { agent, kubernetesNamespace } from '../../../graphql/mock_data';
import { mockKasTunnelUrl, fluxResourceStatus, fluxKustomization } from '../../../mock_data';
@ -40,13 +42,13 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_ov
'kustomize.toolkit.fluxcd.io/v1/namespaces/my-namespace/kustomizations/app';
const fluxKustomizationQuery = jest.fn().mockReturnValue({});
const fluxHelmReleaseStatusQuery = jest.fn().mockReturnValue({});
const fluxHelmReleaseQuery = jest.fn().mockReturnValue({});
const createApolloProvider = () => {
const mockResolvers = {
Query: {
fluxKustomization: fluxKustomizationQuery,
fluxHelmReleaseStatus: fluxHelmReleaseStatusQuery,
fluxHelmRelease: fluxHelmReleaseQuery,
},
};
@ -74,6 +76,8 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_ov
const findKubernetesTabs = () => wrapper.findComponent(KubernetesTabs);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
const findAlert = () => wrapper.findComponent(GlAlert);
const findDrawer = () => wrapper.findComponent(GlDrawer);
const findWorkloadDetails = () => wrapper.findComponent(WorkloadDetails);
describe('when the agent data is present', () => {
it('renders kubernetes agent info', () => {
@ -150,7 +154,7 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_ov
it("doesn't request Kustomizations and HelmReleases", () => {
expect(fluxKustomizationQuery).not.toHaveBeenCalled();
expect(fluxHelmReleaseStatusQuery).not.toHaveBeenCalled();
expect(fluxHelmReleaseQuery).not.toHaveBeenCalled();
});
it('provides empty `fluxResourceStatus` to KubernetesStatusBar', () => {
@ -181,7 +185,7 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_ov
});
it("doesn't request HelmRelease resource status", () => {
expect(fluxHelmReleaseStatusQuery).not.toHaveBeenCalled();
expect(fluxHelmReleaseQuery).not.toHaveBeenCalled();
});
});
@ -194,7 +198,7 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_ov
});
it('requests the HelmRelease resource status', () => {
expect(fluxHelmReleaseStatusQuery).toHaveBeenCalledWith(
expect(fluxHelmReleaseQuery).toHaveBeenCalledWith(
{},
expect.objectContaining({
configuration,
@ -215,7 +219,7 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_ov
const mockResolvers = {
Query: {
fluxKustomization: jest.fn().mockReturnValue(fluxKustomization),
fluxHelmReleaseStatus: fluxHelmReleaseStatusQuery,
fluxHelmRelease: fluxHelmReleaseQuery,
},
};
@ -245,7 +249,7 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_ov
const mockResolvers = {
Query: {
fluxKustomization: jest.fn().mockRejectedValueOnce(error),
fluxHelmReleaseStatus: jest.fn().mockRejectedValueOnce(error),
fluxHelmRelease: jest.fn().mockRejectedValueOnce(error),
},
};
@ -268,6 +272,90 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_ov
});
});
describe('resource details drawer', () => {
it('is closed by default', () => {
wrapper = createWrapper();
expect(findDrawer().props('open')).toBe(false);
});
describe('when receives show-resource-details event from the tabs', () => {
beforeEach(() => {
wrapper = createWrapper();
findKubernetesTabs().vm.$emit('show-resource-details', mockPodsTableItems[0]);
});
it('opens the drawer', () => {
expect(findDrawer().props('open')).toBe(true);
});
it('provides the resource details to the drawer', () => {
expect(findWorkloadDetails().props('item')).toEqual(mockPodsTableItems[0]);
});
it('renders a title with the selected item name', () => {
expect(findDrawer().text()).toContain(mockPodsTableItems[0].name);
});
it('is closed when clicked on a cross button', async () => {
expect(findDrawer().props('open')).toBe(true);
await findDrawer().vm.$emit('close');
expect(findDrawer().props('open')).toBe(false);
});
it('is closed on remove-selection event', async () => {
expect(findDrawer().props('open')).toBe(true);
await findKubernetesTabs().vm.$emit('remove-selection');
expect(findDrawer().props('open')).toBe(false);
});
});
describe('when receives show-flux-resource-details event from the status bar', () => {
beforeEach(async () => {
const createApolloProviderWithKustomizations = () => {
const mockResolvers = {
Query: {
fluxKustomization: jest.fn().mockReturnValue(fluxKustomization),
fluxHelmRelease: fluxHelmReleaseQuery,
},
};
return createMockApollo([], mockResolvers);
};
wrapper = createWrapper({
fluxResourcePath: kustomizationResourcePath,
apolloProvider: createApolloProviderWithKustomizations(),
});
await waitForPromises();
findKubernetesStatusBar().vm.$emit('show-flux-resource-details', fluxKustomization);
});
it('opens the drawer when gets selected item', () => {
expect(findDrawer().props('open')).toBe(true);
});
it('provides the resource details to the drawer', () => {
const selectedItem = {
name: fluxKustomization.metadata.name,
status: 'reconciled',
labels: fluxKustomization.metadata.labels,
annotations: fluxKustomization.metadata.annotations,
kind: fluxKustomization.kind,
spec: fluxKustomization.spec,
fullStatus: fluxKustomization.status.conditions,
};
expect(findWorkloadDetails().props('item')).toEqual(selectedItem);
});
it('renders a title with the selected item name', () => {
expect(findDrawer().text()).toContain(fluxKustomization.metadata.name);
});
});
});
describe('on child component error', () => {
beforeEach(() => {
wrapper = createWrapper();

View File

@ -165,6 +165,10 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_st
it('renders sync status as Unavailable', () => {
expect(findSyncBadge().text()).toBe('Unavailable');
});
it('renders a non-clickable badge', () => {
expect(findSyncBadge().attributes('href')).toBeUndefined();
});
});
describe('when flux status data is provided', () => {
@ -197,28 +201,45 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_st
},
);
describe('when Flux API errored', () => {
const fluxApiError = 'Error from the cluster_client API';
beforeEach(() => {
createWrapper({ fluxApiError });
it('renders a clickable badge', () => {
createWrapper({
fluxResourceStatus: [{ status: 'True', type: 'Ready' }],
});
it('renders sync badge as unavailable', () => {
const badge = SYNC_STATUS_BADGES.unavailable;
expect(findSyncBadge().attributes('href')).toBe('#');
});
expect(findSyncBadge().text()).toBe(badge.text);
expect(findSyncBadge().props()).toMatchObject({
icon: badge.icon,
variant: badge.variant,
});
it('emits `show-flux-resource-details` event when badge is clicked', () => {
createWrapper({
fluxResourceStatus: [{ status: 'True', type: 'Ready' }],
});
it('renders popover with an API error message', () => {
expect(findPopover().text()).toBe(fluxApiError);
expect(findPopover().props('title')).toBe('Flux sync status is unavailable');
findSyncBadge().trigger('click');
expect(wrapper.emitted('show-flux-resource-details')).toBeDefined();
});
});
describe('when Flux API errored', () => {
const fluxApiError = 'Error from the cluster_client API';
beforeEach(() => {
createWrapper({ fluxApiError });
});
it('renders sync badge as unavailable', () => {
const badge = SYNC_STATUS_BADGES.unavailable;
expect(findSyncBadge().text()).toBe(badge.text);
expect(findSyncBadge().props()).toMatchObject({
icon: badge.icon,
variant: badge.variant,
});
});
it('renders popover with an API error message', () => {
expect(findPopover().text()).toBe(fluxApiError);
expect(findPopover().props('title')).toBe('Flux sync status is unavailable');
});
});
});
});

View File

@ -1,11 +1,10 @@
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import { GlTabs, GlDrawer } from '@gitlab/ui';
import { GlTabs } from '@gitlab/ui';
import KubernetesTabs from '~/environments/environment_details/components/kubernetes/kubernetes_tabs.vue';
import KubernetesPods from '~/environments/environment_details/components/kubernetes/kubernetes_pods.vue';
import KubernetesServices from '~/environments/environment_details/components/kubernetes/kubernetes_services.vue';
import KubernetesSummary from '~/environments/environment_details/components/kubernetes/kubernetes_summary.vue';
import WorkloadDetails from '~/kubernetes_dashboard/components/workload_details.vue';
import { k8sResourceType } from '~/environments/graphql/resolvers/kubernetes/constants';
import { mockKasTunnelUrl, fluxKustomization } from 'jest/environments/mock_data';
import { mockPodsTableItems } from 'jest/kubernetes_dashboard/graphql/mock_data';
@ -25,8 +24,6 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_ta
const findKubernetesPods = () => wrapper.findComponent(KubernetesPods);
const findKubernetesServices = () => wrapper.findComponent(KubernetesServices);
const findKubernetesSummary = () => wrapper.findComponent(KubernetesSummary);
const findDrawer = () => wrapper.findComponent(GlDrawer);
const findWorkloadDetails = () => wrapper.findComponent(WorkloadDetails);
const createWrapper = ({
activeTab = k8sResourceType.k8sPods,
@ -37,7 +34,6 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_ta
glFeatures: { k8sTreeView: k8sTreeViewEnabled },
},
propsData: { configuration, namespace, fluxKustomization, value: activeTab },
stubs: { GlDrawer },
});
};
@ -147,52 +143,22 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_ta
expect(wrapper.emitted('update-failed-state')).toEqual([[eventData]]);
});
it('emits show-resource-details event when gets it from the component', () => {
findKubernetesPods().vm.$emit('show-resource-details', mockPodsTableItems[0]);
expect(wrapper.emitted('show-resource-details')).toEqual([[mockPodsTableItems[0]]]);
});
it('emits remove-selection event when gets it from the component', () => {
findKubernetesPods().vm.$emit('remove-selection');
expect(wrapper.emitted('remove-selection')).toBeDefined();
});
it('emits a cluster error event when gets it from the component', () => {
const errorMessage = 'Error from the cluster_client API';
findKubernetesPods().vm.$emit('cluster-error', errorMessage);
expect(wrapper.emitted('cluster-error')).toEqual([[errorMessage]]);
});
});
describe('resource details drawer', () => {
beforeEach(() => {
createWrapper();
});
it('is closed by default', () => {
expect(findDrawer().props('open')).toBe(false);
});
describe('when receives show-resource-details event', () => {
beforeEach(() => {
findKubernetesPods().vm.$emit('show-resource-details', mockPodsTableItems[0]);
});
it('opens the drawer', () => {
expect(findDrawer().props('open')).toBe(true);
});
it('provides the resource details to the drawer', () => {
expect(findWorkloadDetails().props('item')).toEqual(mockPodsTableItems[0]);
});
it('renders a title with the selected item name', () => {
expect(findDrawer().text()).toContain(mockPodsTableItems[0].name);
});
it('is closed when clicked on a cross button', async () => {
expect(findDrawer().props('open')).toBe(true);
await findDrawer().vm.$emit('close');
expect(findDrawer().props('open')).toBe(false);
});
it('is closed on remove-selection event', async () => {
expect(findDrawer().props('open')).toBe(true);
await findKubernetesPods().vm.$emit('remove-selection');
expect(findDrawer().props('open')).toBe(false);
});
});
});
});

View File

@ -816,19 +816,38 @@ export const k8sNamespacesMock = [
];
const fluxResourceStatusMock = [{ status: 'True', type: 'Ready', message: '', reason: '' }];
const fluxResourceMetadataMock = {
name: 'custom-resource',
namespace: 'custom-namespace',
annotations: {},
labels: {},
};
export const fluxKustomizationMock = {
kind: 'Kustomization',
metadata: { name: 'custom-resource', namespace: 'custom-namespace' },
metadata: fluxResourceMetadataMock,
status: { conditions: fluxResourceStatusMock, inventory: { entries: [{ id: 'test_resource' }] } },
};
export const fluxHelmReleaseMock = {
kind: 'HelmRelease',
metadata: fluxResourceMetadataMock,
status: { conditions: fluxResourceStatusMock },
};
export const fluxKustomizationMapped = {
kind: 'Kustomization',
metadata: { name: 'custom-resource' },
metadata: fluxResourceMetadataMock,
spec: {},
status: fluxKustomizationMock.status,
conditions: fluxResourceStatusMock,
inventory: [{ id: 'test_resource' }],
__typename: 'LocalWorkloadItem',
};
export const fluxHelmReleaseMapped = {
kind: 'HelmRelease',
metadata: fluxResourceMetadataMock,
spec: {},
status: { conditions: fluxResourceStatusMock },
conditions: fluxResourceStatusMock,
__typename: 'LocalWorkloadItem',
};
export const fluxResourcePathMock = 'kustomize.toolkit.fluxcd.io/v1/path/to/flux/resource';

View File

@ -10,6 +10,7 @@ import {
} from '~/environments/graphql/resolvers/kubernetes/constants';
import {
fluxKustomizationMock,
fluxHelmReleaseMock,
fluxKustomizationMapped,
fluxHelmReleaseMapped,
} from '../mock_data';
@ -144,7 +145,7 @@ describe('~/frontend/environments/graphql/resolvers', () => {
});
});
describe('fluxHelmReleaseStatus', () => {
describe('fluxHelmRelease', () => {
const client = { writeQuery: jest.fn() };
const fluxResourcePath =
'helm.toolkit.fluxcd.io/v2beta1/namespaces/my-namespace/helmreleases/app';
@ -156,7 +157,7 @@ describe('~/frontend/environments/graphql/resolvers', () => {
});
const mockOnDataFn = jest.fn().mockImplementation((eventName, callback) => {
if (eventName === 'data') {
callback([fluxKustomizationMock]);
callback([fluxHelmReleaseMock]);
}
});
const resourceName = 'custom-resource';
@ -174,11 +175,11 @@ describe('~/frontend/environments/graphql/resolvers', () => {
.onGet(endpoint, { withCredentials: true, headers: configuration.baseOptions.headers })
.reply(HTTP_STATUS_OK, {
apiVersion,
...fluxKustomizationMock,
...fluxHelmReleaseMock,
});
});
it('should watch HelmRelease by the metadata name from the cluster_client library when the data is present', async () => {
await mockResolvers.Query.fluxHelmReleaseStatus(
await mockResolvers.Query.fluxHelmRelease(
null,
{
configuration,
@ -197,7 +198,7 @@ describe('~/frontend/environments/graphql/resolvers', () => {
});
it('should return data when received from the library', async () => {
const fluxHelmReleaseStatus = await mockResolvers.Query.fluxHelmReleaseStatus(
const fluxHelmRelease = await mockResolvers.Query.fluxHelmRelease(
null,
{
configuration,
@ -206,7 +207,7 @@ describe('~/frontend/environments/graphql/resolvers', () => {
{ client },
);
expect(fluxHelmReleaseStatus).toEqual(fluxHelmReleaseMapped);
expect(fluxHelmRelease).toEqual(fluxHelmReleaseMapped);
});
});
@ -215,7 +216,7 @@ describe('~/frontend/environments/graphql/resolvers', () => {
.onGet(endpoint, { withCredentials: true, headers: configuration.baseOptions.headers })
.reply(HTTP_STATUS_OK, {});
await mockResolvers.Query.fluxHelmReleaseStatus(
await mockResolvers.Query.fluxHelmRelease(
null,
{
configuration,
@ -233,7 +234,7 @@ describe('~/frontend/environments/graphql/resolvers', () => {
.onGet(endpoint, { withCredentials: true, headers: configuration.base })
.reply(HTTP_STATUS_UNAUTHORIZED, { message: apiError });
const fluxHelmReleasesError = mockResolvers.Query.fluxHelmReleaseStatus(
const fluxHelmReleasesError = mockResolvers.Query.fluxHelmRelease(
null,
{
configuration,

View File

@ -318,12 +318,21 @@ const mockKasTunnelUrl = 'https://kas.gitlab.com/k8s-proxy';
const fluxResourceStatus = [{ status: 'True', type: 'Ready', message: '', reason: '' }];
const fluxKustomization = {
kind: 'Kustomization',
metadata: { name: 'my-kustomization' },
status: { conditions: fluxResourceStatus },
spec: {},
metadata: {
name: 'my-kustomization',
namespace: 'my-namespace',
creationTimestamp: '',
labels: {},
annotations: {},
},
conditions: fluxResourceStatus,
inventory: [
{ id: 'flux-system_notification-controller_apps_Deployment' },
{ id: 'flux-system_source-controller_apps_Deployment' },
],
__typename: 'LocalWorkloadItem',
};
const k8sDeploymentsMock = [

View File

@ -1,4 +1,4 @@
import { GlBadge, GlIcon } from '@gitlab/ui';
import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import HiddenBadge from '~/issuable/components/hidden_badge.vue';
@ -18,19 +18,18 @@ describe('HiddenBadge component', () => {
};
const findBadge = () => wrapper.findComponent(GlBadge);
const findIcon = () => wrapper.findComponent(GlIcon);
beforeEach(() => {
mountComponent();
});
it('renders warning badge', () => {
expect(findBadge().text()).toBe('Hidden');
expect(findBadge().attributes('aria-label')).toBe('Hidden');
expect(findBadge().props('variant')).toEqual('warning');
});
it('renders spam icon', () => {
expect(findIcon().props('name')).toBe('spam');
expect(findBadge().props('icon')).toBe('spam');
});
it('has tooltip', () => {

View File

@ -1,4 +1,4 @@
import { GlBadge, GlIcon } from '@gitlab/ui';
import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import LockedBadge from '~/issuable/components/locked_badge.vue';
@ -18,19 +18,20 @@ describe('LockedBadge component', () => {
};
const findBadge = () => wrapper.findComponent(GlBadge);
const findIcon = () => wrapper.findComponent(GlIcon);
beforeEach(() => {
mountComponent();
});
it('renders warning badge', () => {
expect(findBadge().text()).toBe('Locked');
expect(findBadge().attributes('aria-label')).toBe(
'The discussion in this issue is locked. Only project members can comment.',
);
expect(findBadge().props('variant')).toEqual('warning');
});
it('renders lock icon', () => {
expect(findIcon().props('name')).toBe('lock');
expect(findBadge().props('icon')).toBe('lock');
});
it('has tooltip', () => {

View File

@ -1,4 +1,4 @@
import { GlBadge, GlIcon } from '@gitlab/ui';
import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import StatusBadge from '~/issuable/components/status_badge.vue';
@ -36,7 +36,7 @@ describe('StatusBadge component', () => {
});
it(`sets badge icon as '${badgeIcon}'`, () => {
expect(findBadge().findComponent(GlIcon).props('name')).toBe(badgeIcon);
expect(findBadge().props('icon')).toBe(badgeIcon);
});
},
);

View File

@ -5,8 +5,10 @@ import { createAlert } from '~/alert';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import PersistentUserCallout from '~/persistent_user_callout';
import { visitUrl } from '~/lib/utils/url_utility';
jest.mock('~/alert');
jest.mock('~/lib/utils/url_utility');
describe('PersistentUserCallout', () => {
const dismissEndpoint = '/dismiss';
@ -121,7 +123,6 @@ describe('PersistentUserCallout', () => {
let normalLink;
let mockAxios;
let persistentUserCallout;
let windowSpy;
beforeEach(() => {
const fixture = createDeferredLinkFixture();
@ -132,7 +133,6 @@ describe('PersistentUserCallout', () => {
mockAxios = new MockAdapter(axios);
persistentUserCallout = new PersistentUserCallout(container);
jest.spyOn(persistentUserCallout.container, 'remove').mockImplementation(() => {});
windowSpy = jest.spyOn(window, 'open').mockImplementation(() => {});
});
afterEach(() => {
@ -140,14 +140,14 @@ describe('PersistentUserCallout', () => {
});
it('defers loading of a link until callout is dismissed', async () => {
const { href, target } = deferredLink;
const { href } = deferredLink;
mockAxios.onPost(dismissEndpoint).replyOnce(HTTP_STATUS_OK);
deferredLink.click();
await waitForPromises();
expect(windowSpy).toHaveBeenCalledWith(href, target);
expect(visitUrl).toHaveBeenCalledWith(href, true);
expect(persistentUserCallout.container.remove).toHaveBeenCalled();
expect(mockAxios.history.post[0].data).toBe(JSON.stringify({ feature_name: featureName }));
});
@ -157,7 +157,7 @@ describe('PersistentUserCallout', () => {
await waitForPromises();
expect(windowSpy).not.toHaveBeenCalled();
expect(visitUrl).not.toHaveBeenCalled();
expect(persistentUserCallout.container.remove).not.toHaveBeenCalled();
});
@ -168,7 +168,7 @@ describe('PersistentUserCallout', () => {
await waitForPromises();
expect(windowSpy).not.toHaveBeenCalled();
expect(visitUrl).not.toHaveBeenCalled();
expect(persistentUserCallout.container.remove).toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show More