Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-07-16 18:07:28 +00:00
parent b744b11e7d
commit 0a0fc6fa1a
50 changed files with 790 additions and 162 deletions

View File

@ -7,7 +7,7 @@ workflow:
include:
- local: .gitlab/ci/version.yml
- local: .gitlab/ci/global.gitlab-ci.yml
- component: "gitlab.com/gitlab-org/quality/pipeline-common/allure-report@11.9.0"
- component: "gitlab.com/gitlab-org/quality/pipeline-common/allure-report@11.10.0"
inputs:
job_name: "e2e-test-report"
job_stage: "report"
@ -17,7 +17,7 @@ include:
gitlab_auth_token_variable_name: "PROJECT_TOKEN_FOR_CI_SCRIPTS_API_USAGE"
allure_job_name: "${QA_RUN_TYPE}"
- project: gitlab-org/quality/pipeline-common
ref: 11.9.0
ref: 11.10.0
file:
- /ci/notify-slack.gitlab-ci.yml
- /ci/qa-report.gitlab-ci.yml

View File

@ -149,7 +149,7 @@ detect-tests:
if [ -n "$CI_MERGE_REQUEST_IID" ] || [ -n "$FIND_CHANGES_MERGE_REQUEST_IID" ]; then
mkdir -p $(dirname "$RSPEC_CHANGED_FILES_PATH")
tooling/bin/predictive_tests --select-tests --with-crystalball-mappings --mapping-type $MAPPING_TYPE
tooling/bin/predictive_tests --ci --select-tests --with-crystalball-mappings --mapping-type $MAPPING_TYPE
filter_rspec_matched_foss_tests ${RSPEC_MATCHING_TEST_FILES_PATH} ${RSPEC_MATCHING_TESTS_FOSS_PATH};
filter_rspec_matched_ee_tests ${RSPEC_MATCHING_TEST_FILES_PATH} ${RSPEC_MATCHING_TESTS_EE_PATH};

View File

@ -81,27 +81,17 @@ export-predictive-test-metrics:
allow_failure: true
dependencies: []
variables:
GLCI_CRYSTALBALL_MAPPING_DIR: "tmp/crystalball_mappings"
GLCI_PREDICTIVE_TEST_METRICS_OUTPUT_DIR: "tmp/predictive_tests"
GLCI_ALL_FAILED_RSPEC_TESTS_FILE: "${GLCI_PREDICTIVE_TEST_METRICS_OUTPUT_DIR}/rspec_all_failed_tests.txt"
before_script:
- apt update && apt install -y curl
- source ./scripts/utils.sh
- source ./scripts/rspec_helpers.sh
- retrieve_failed_tests "${GLCI_PREDICTIVE_TEST_METRICS_OUTPUT_DIR}" "oneline" "latest"
- retrieve_frontend_fixtures_mapping
- |
mkdir -p "$GLCI_CRYSTALBALL_MAPPING_DIR/described_class"
retrieve_tests_mapping "$RSPEC_PACKED_TESTS_MAPPING_PATH" "$GLCI_CRYSTALBALL_MAPPING_DIR/described_class/mapping.json"
- |
mkdir -p "$GLCI_CRYSTALBALL_MAPPING_DIR/coverage"
retrieve_tests_mapping "$RSPEC_PACKED_TESTS_MAPPING_ALT_PATH" "$GLCI_CRYSTALBALL_MAPPING_DIR/coverage/mapping.json"
script:
- tooling/bin/predictive_tests --export-predictive-backend-metrics
artifacts:
expire_in: 7d
paths:
- $GLCI_CRYSTALBALL_MAPPING_DIR
- $GLCI_PREDICTIVE_TEST_METRICS_OUTPUT_DIR
export-predictive-test-metrics-frontend:

View File

@ -231,7 +231,7 @@ gem 'google-apis-serviceusage_v1', '~> 0.28.0', feature_category: :shared
gem 'google-apis-sqladmin_v1beta4', '~> 0.41.0', feature_category: :shared
gem 'google-apis-androidpublisher_v3', '~> 0.34.0', feature_category: :shared
gem 'googleauth', '~> 1.8.1', feature_category: :shared
gem 'googleauth', '~> 1.14', feature_category: :shared
gem 'google-cloud-artifact_registry-v1', '~> 0.11.0', feature_category: :shared
gem 'google-cloud-compute-v1', '~> 2.6.0', feature_category: :shared

View File

@ -264,12 +264,13 @@
{"name":"google-cloud-common","version":"1.1.0","platform":"ruby","checksum":"738db08fd144b4fe37b4578ffd63308b64a86fd59f6979d240048f917a6fb5fb"},
{"name":"google-cloud-compute-v1","version":"2.6.0","platform":"ruby","checksum":"b96059b33ffc2f25644d20161a0c1aa1331197073c2e44786b18f8b670f1141e"},
{"name":"google-cloud-core","version":"1.7.0","platform":"ruby","checksum":"748028a48530ea5bce159722eb7a02cd0562f1c52f0569e9ed69da3cba6b4f35"},
{"name":"google-cloud-env","version":"2.1.1","platform":"ruby","checksum":"cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999"},
{"name":"google-cloud-env","version":"2.2.1","platform":"ruby","checksum":"3c6062aee0b5c863b83f3ce125ea7831507aadf1af7c0d384b74a116c4f649cf"},
{"name":"google-cloud-errors","version":"1.3.0","platform":"ruby","checksum":"450b681e24c089a20721a01acc4408bb4a7b0df28c175aaab488da917480d64b"},
{"name":"google-cloud-location","version":"0.6.0","platform":"ruby","checksum":"386c99ca156e5cac413731c055d7d9c55629860129ad7658a2bf39ea5004d2d0"},
{"name":"google-cloud-storage","version":"1.45.0","platform":"ruby","checksum":"f280abda4e608f9e91433f9dd907be4a45cdbf251ffeb275d713548e515c6300"},
{"name":"google-cloud-storage_transfer","version":"1.2.0","platform":"ruby","checksum":"132901f50889e02a0d378e6117c6408cbfc4fdbd15c9d31fabec4f4189ef1658"},
{"name":"google-cloud-storage_transfer-v1","version":"0.8.0","platform":"ruby","checksum":"9dbef80275db556e046bb24139ca6559affe641d1e38b2537b8caaf2f8896176"},
{"name":"google-logging-utils","version":"0.1.0","platform":"ruby","checksum":"70950b1e49314273cf2e167adb47b62af7917a4691b580da7e9be67b9205fcd5"},
{"name":"google-protobuf","version":"3.25.8","platform":"aarch64-linux","checksum":"5869d1a31f39ee3361e85f3ef3db0512c19f0e0c75cd69d7303c177e17590044"},
{"name":"google-protobuf","version":"3.25.8","platform":"arm64-darwin","checksum":"e294affc4fb25c8bc7edd264f0ba490d42dce3afff08db1e08fb7bb44cc57488"},
{"name":"google-protobuf","version":"3.25.8","platform":"java","checksum":"1b8dd949116795653347f95d7975ce2897de2adf721647c10bf54d30ab87fd1e"},
@ -282,7 +283,7 @@
{"name":"google-protobuf","version":"3.25.8","platform":"x86_64-linux","checksum":"07783d910635e2a60eaf929fe3e45ea4d657d023be3816bda99a21c14f7be959"},
{"name":"googleapis-common-protos","version":"1.4.0","platform":"ruby","checksum":"da2380fb5ab1563580816c74e8d684ac17512c3654c829a3ee84f6d6139de382"},
{"name":"googleapis-common-protos-types","version":"1.20.0","platform":"ruby","checksum":"5e374b06bcfc7e13556e7c0d87b99f1fa3d42de6396a1de3d8fc13aefb4dd07f"},
{"name":"googleauth","version":"1.8.1","platform":"ruby","checksum":"814adadaaa1221dce72a67131e3ecbd6d23491a161ec84fb15fd353b87d8c9e7"},
{"name":"googleauth","version":"1.14.0","platform":"ruby","checksum":"62e7de11791890c3d3dc70582dfd9ab5516530e4e4f56d96451fd62c76475149"},
{"name":"gpgme","version":"2.0.24","platform":"ruby","checksum":"53eccd7042abb4fd5c78f30bc9ed075b1325e6450eab207f2f6a1e7e28ae3b64"},
{"name":"grape","version":"2.0.0","platform":"ruby","checksum":"3aeff94c17e84ccead4ff98833df691e7da0c108878cc128ca31f80c1047494a"},
{"name":"grape-entity","version":"1.0.1","platform":"ruby","checksum":"e00f9e94e407aff77aa2945d741f544d07e48501927942988799913151d02634"},
@ -697,7 +698,7 @@
{"name":"shoulda-matchers","version":"6.4.0","platform":"ruby","checksum":"9055bb7f4bb342125fb860809798855c630e05ef5e75837b3168b8e6ee1608b0"},
{"name":"sidekiq-cron","version":"1.12.0","platform":"ruby","checksum":"6663080a454088bd88773a0da3ae91e554b8a2e8b06cfc629529a83fd1a3096c"},
{"name":"sigdump","version":"0.2.5","platform":"ruby","checksum":"bb706c1cce70458b285d2c3a57121e801ccb79f68be7f7377692eb40b5437242"},
{"name":"signet","version":"0.18.0","platform":"ruby","checksum":"66cda8c2edc2dde25090b792e7e6fc9598c3c2bdd64ffacd89f1ffe3cb9cea3b"},
{"name":"signet","version":"0.19.0","platform":"ruby","checksum":"537f3939f57f141f691e6069a97ec40f34fadafc4c7e5ba94edb06cf4350dd31"},
{"name":"simple_po_parser","version":"1.1.6","platform":"ruby","checksum":"122687d44d3de516a0e69e2f383a4180f5015e8c5ed5a7f2258f2b376f64cbf3"},
{"name":"simplecov","version":"0.22.0","platform":"ruby","checksum":"fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5"},
{"name":"simplecov-cobertura","version":"2.1.0","platform":"ruby","checksum":"2c6532e34df2e38a379d72cef9a05c3b16c64ce90566beebc6887801c4ad3f02"},

View File

@ -53,7 +53,7 @@ PATH
faraday (~> 2)
google-cloud-storage_transfer (~> 1.2.0)
google-protobuf (~> 3.25, >= 3.25.3)
googleauth (~> 1.8.1)
googleauth (~> 1.14)
grpc (= 1.63.0)
json (~> 2.7)
jwt (~> 2.5)
@ -885,7 +885,7 @@ GEM
google-cloud-core (1.7.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
google-cloud-env (2.2.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.3.0)
google-cloud-location (0.6.0)
@ -905,6 +905,7 @@ GEM
google-cloud-storage_transfer-v1 (0.8.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-logging-utils (0.1.0)
google-protobuf (3.25.8)
googleapis-common-protos (1.4.0)
google-protobuf (~> 3.14)
@ -912,8 +913,10 @@ GEM
grpc (~> 1.27)
googleapis-common-protos-types (1.20.0)
google-protobuf (>= 3.18, < 5.a)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
googleauth (1.14.0)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
@ -1793,7 +1796,7 @@ GEM
globalid (>= 1.0.1)
sidekiq (>= 6)
sigdump (0.2.5)
signet (0.18.0)
signet (0.19.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
@ -2193,7 +2196,7 @@ DEPENDENCIES
google-cloud-compute-v1 (~> 2.6.0)
google-cloud-storage (~> 1.45.0)
google-protobuf (~> 3.25, >= 3.25.3)
googleauth (~> 1.8.1)
googleauth (~> 1.14)
gpgme (~> 2.0.24)
grape (~> 2.0.0)
grape-entity (~> 1.0.1)

View File

@ -264,12 +264,13 @@
{"name":"google-cloud-common","version":"1.1.0","platform":"ruby","checksum":"738db08fd144b4fe37b4578ffd63308b64a86fd59f6979d240048f917a6fb5fb"},
{"name":"google-cloud-compute-v1","version":"2.6.0","platform":"ruby","checksum":"b96059b33ffc2f25644d20161a0c1aa1331197073c2e44786b18f8b670f1141e"},
{"name":"google-cloud-core","version":"1.7.0","platform":"ruby","checksum":"748028a48530ea5bce159722eb7a02cd0562f1c52f0569e9ed69da3cba6b4f35"},
{"name":"google-cloud-env","version":"2.1.1","platform":"ruby","checksum":"cf4bb8c7d517ee1ea692baedf06e0b56ce68007549d8d5a66481aa9f97f46999"},
{"name":"google-cloud-env","version":"2.2.1","platform":"ruby","checksum":"3c6062aee0b5c863b83f3ce125ea7831507aadf1af7c0d384b74a116c4f649cf"},
{"name":"google-cloud-errors","version":"1.3.0","platform":"ruby","checksum":"450b681e24c089a20721a01acc4408bb4a7b0df28c175aaab488da917480d64b"},
{"name":"google-cloud-location","version":"0.6.0","platform":"ruby","checksum":"386c99ca156e5cac413731c055d7d9c55629860129ad7658a2bf39ea5004d2d0"},
{"name":"google-cloud-storage","version":"1.45.0","platform":"ruby","checksum":"f280abda4e608f9e91433f9dd907be4a45cdbf251ffeb275d713548e515c6300"},
{"name":"google-cloud-storage_transfer","version":"1.2.0","platform":"ruby","checksum":"132901f50889e02a0d378e6117c6408cbfc4fdbd15c9d31fabec4f4189ef1658"},
{"name":"google-cloud-storage_transfer-v1","version":"0.8.0","platform":"ruby","checksum":"9dbef80275db556e046bb24139ca6559affe641d1e38b2537b8caaf2f8896176"},
{"name":"google-logging-utils","version":"0.1.0","platform":"ruby","checksum":"70950b1e49314273cf2e167adb47b62af7917a4691b580da7e9be67b9205fcd5"},
{"name":"google-protobuf","version":"3.25.8","platform":"aarch64-linux","checksum":"5869d1a31f39ee3361e85f3ef3db0512c19f0e0c75cd69d7303c177e17590044"},
{"name":"google-protobuf","version":"3.25.8","platform":"arm64-darwin","checksum":"e294affc4fb25c8bc7edd264f0ba490d42dce3afff08db1e08fb7bb44cc57488"},
{"name":"google-protobuf","version":"3.25.8","platform":"java","checksum":"1b8dd949116795653347f95d7975ce2897de2adf721647c10bf54d30ab87fd1e"},
@ -282,7 +283,7 @@
{"name":"google-protobuf","version":"3.25.8","platform":"x86_64-linux","checksum":"07783d910635e2a60eaf929fe3e45ea4d657d023be3816bda99a21c14f7be959"},
{"name":"googleapis-common-protos","version":"1.4.0","platform":"ruby","checksum":"da2380fb5ab1563580816c74e8d684ac17512c3654c829a3ee84f6d6139de382"},
{"name":"googleapis-common-protos-types","version":"1.20.0","platform":"ruby","checksum":"5e374b06bcfc7e13556e7c0d87b99f1fa3d42de6396a1de3d8fc13aefb4dd07f"},
{"name":"googleauth","version":"1.8.1","platform":"ruby","checksum":"814adadaaa1221dce72a67131e3ecbd6d23491a161ec84fb15fd353b87d8c9e7"},
{"name":"googleauth","version":"1.14.0","platform":"ruby","checksum":"62e7de11791890c3d3dc70582dfd9ab5516530e4e4f56d96451fd62c76475149"},
{"name":"gpgme","version":"2.0.24","platform":"ruby","checksum":"53eccd7042abb4fd5c78f30bc9ed075b1325e6450eab207f2f6a1e7e28ae3b64"},
{"name":"grape","version":"2.0.0","platform":"ruby","checksum":"3aeff94c17e84ccead4ff98833df691e7da0c108878cc128ca31f80c1047494a"},
{"name":"grape-entity","version":"1.0.1","platform":"ruby","checksum":"e00f9e94e407aff77aa2945d741f544d07e48501927942988799913151d02634"},
@ -697,7 +698,7 @@
{"name":"shoulda-matchers","version":"6.4.0","platform":"ruby","checksum":"9055bb7f4bb342125fb860809798855c630e05ef5e75837b3168b8e6ee1608b0"},
{"name":"sidekiq-cron","version":"1.12.0","platform":"ruby","checksum":"6663080a454088bd88773a0da3ae91e554b8a2e8b06cfc629529a83fd1a3096c"},
{"name":"sigdump","version":"0.2.5","platform":"ruby","checksum":"bb706c1cce70458b285d2c3a57121e801ccb79f68be7f7377692eb40b5437242"},
{"name":"signet","version":"0.18.0","platform":"ruby","checksum":"66cda8c2edc2dde25090b792e7e6fc9598c3c2bdd64ffacd89f1ffe3cb9cea3b"},
{"name":"signet","version":"0.19.0","platform":"ruby","checksum":"537f3939f57f141f691e6069a97ec40f34fadafc4c7e5ba94edb06cf4350dd31"},
{"name":"simple_po_parser","version":"1.1.6","platform":"ruby","checksum":"122687d44d3de516a0e69e2f383a4180f5015e8c5ed5a7f2258f2b376f64cbf3"},
{"name":"simplecov","version":"0.22.0","platform":"ruby","checksum":"fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5"},
{"name":"simplecov-cobertura","version":"2.1.0","platform":"ruby","checksum":"2c6532e34df2e38a379d72cef9a05c3b16c64ce90566beebc6887801c4ad3f02"},

View File

@ -53,7 +53,7 @@ PATH
faraday (~> 2)
google-cloud-storage_transfer (~> 1.2.0)
google-protobuf (~> 3.25, >= 3.25.3)
googleauth (~> 1.8.1)
googleauth (~> 1.14)
grpc (= 1.63.0)
json (~> 2.7)
jwt (~> 2.5)
@ -879,7 +879,7 @@ GEM
google-cloud-core (1.7.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.1.1)
google-cloud-env (2.2.1)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.3.0)
google-cloud-location (0.6.0)
@ -899,6 +899,7 @@ GEM
google-cloud-storage_transfer-v1 (0.8.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-logging-utils (0.1.0)
google-protobuf (3.25.8)
googleapis-common-protos (1.4.0)
google-protobuf (~> 3.14)
@ -906,8 +907,10 @@ GEM
grpc (~> 1.27)
googleapis-common-protos-types (1.20.0)
google-protobuf (>= 3.18, < 5.a)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
googleauth (1.14.0)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
@ -1787,7 +1790,7 @@ GEM
globalid (>= 1.0.1)
sidekiq (>= 6)
sigdump (0.2.5)
signet (0.18.0)
signet (0.19.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 3.0)
@ -2188,7 +2191,7 @@ DEPENDENCIES
google-cloud-compute-v1 (~> 2.6.0)
google-cloud-storage (~> 1.45.0)
google-protobuf (~> 3.25, >= 3.25.3)
googleauth (~> 1.8.1)
googleauth (~> 1.14)
gpgme (~> 2.0.24)
grape (~> 2.0.0)
grape-entity (~> 1.0.1)

View File

@ -323,11 +323,12 @@ export default {
<!--Timezone-->
<gl-form-group
:label="$options.i18n.cronTimezoneText"
label-for="schedule-timezone"
label-for="user_timezone"
class="lg:gl-w-2/3"
>
<timezone-dropdown
id="schedule-timezone"
input-id="user_timezone"
:value="cronTimezone"
:timezone-data="timezoneData"
name="schedule-timezone"

View File

@ -136,7 +136,7 @@ export default {
},
computed: {
...mapState(useNotes, [
'getDiscussionLastNote',
'getDiscussionCurrentUserLastNote',
'getNoteableData',
'getNotesDataByProp',
'getUserDataByProp',
@ -215,13 +215,7 @@ export default {
return !this.updatedNoteBody.length || this.isSubmitting;
},
isInternalNote() {
return this.discussionNote.internal || this.discussion.confidential;
},
discussionNote() {
const discussionNote = this.discussion.id
? this.getDiscussionLastNote(this.discussion)
: this.note;
return discussionNote || {};
return this.discussion.confidential;
},
canSuggest() {
return (
@ -279,7 +273,7 @@ export default {
},
editMyLastNote() {
if (this.updatedNoteBody === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion);
const lastNoteInDiscussion = this.getDiscussionCurrentUserLastNote(this.discussion);
if (lastNoteInDiscussion) {
eventHub.$emit('enterEditMode', {
@ -299,7 +293,7 @@ export default {
}
},
updatePlaceholder() {
this.formFieldProps.placeholder = this.discussionNote?.internal
this.formFieldProps.placeholder = this.isInternalNote
? this.$options.i18n.bodyPlaceholderInternal
: this.$options.i18n.bodyPlaceholder;
},
@ -398,7 +392,7 @@ export default {
<div class="flash-container"></div>
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
<comment-field-layout
:is-internal-note="discussionNote.internal"
:is-internal-note="isInternalNote"
:note="updatedNoteBody"
:noteable-data="getNoteableData"
>
@ -409,7 +403,6 @@ export default {
:markdown-docs-path="markdownDocsPath"
:code-suggestions-config="codeSuggestionsConfig"
:help-page-path="helpPagePath"
:note="discussionNote"
:noteable-type="noteableType"
:form-field-props="formFieldProps"
:autosave-key="autosaveKey"

View File

@ -166,17 +166,18 @@ export function noteableType() {
const reverseNotes = (array) => array.slice(0).reverse();
const isLastNote = (note, state) =>
const isCurrentUserLastNote = (note, state) =>
!note.system && state.userData && note.author && note.author.id === state.userData.id;
export function getCurrentUserLastNote() {
return flattenDeep(reverseNotes(this.discussions).map((note) => reverseNotes(note.notes))).find(
(el) => isLastNote(el, this),
(el) => isCurrentUserLastNote(el, this),
);
}
export function getDiscussionLastNote() {
return (discussion) => reverseNotes(discussion.notes).find((el) => isLastNote(el, this));
export function getDiscussionCurrentUserLastNote() {
return (discussion) =>
reverseNotes(discussion.notes).find((el) => isCurrentUserLastNote(el, this));
}
export function showJumpToNextDiscussion() {

View File

@ -165,17 +165,14 @@ export const noteableType = (state) => {
const reverseNotes = (array) => array.slice(0).reverse();
const isLastNote = (note, state) =>
const isCurrentUserLastNote = (note, state) =>
!note.system && state.userData && note.author && note.author.id === state.userData.id;
export const getCurrentUserLastNote = (state) =>
flattenDeep(reverseNotes(state.discussions).map((note) => reverseNotes(note.notes))).find((el) =>
isLastNote(el, state),
isCurrentUserLastNote(el, state),
);
export const getDiscussionLastNote = (state) => (discussion) =>
reverseNotes(discussion.notes).find((el) => isLastNote(el, state));
export const unresolvedDiscussionsCount = (state) => state.unresolvedDiscussionsCount;
export const resolvableDiscussionsCount = (state) => state.resolvableDiscussionsCount;

View File

@ -163,7 +163,7 @@ export default {
<div>
<p class="gl-mb-3 gl-mt-0 gl-text-subtle" data-testid="worker-cron-expression-hint">
{{ sprintf($options.i18n.pipelineScheduleWorkerExplanation, { workerCronExpression }) }}
<gl-link :href="pipelineScheduleWorkerUrl" target="_blank">
<gl-link :href="pipelineScheduleWorkerUrl" target="_blank" variant="inline">
{{ $options.i18n.pipelineScheduleWorkerLink }}
</gl-link>
</p>
@ -197,7 +197,7 @@ export default {
/>
<p class="gl-mb-0 gl-mt-1 gl-text-subtle">
{{ $options.i18n.learnCronSyntax }}
<gl-link :href="cronSyntaxUrl" target="_blank">
<gl-link :href="cronSyntaxUrl" target="_blank" variant="inline">
{{ $options.i18n.cronSyntaxLink }}
</gl-link>
</p>

View File

@ -32,12 +32,7 @@ export default {
};
</script>
<template>
<gl-link
v-gl-tooltip
:href="authorUrl"
:title="showAuthorName ? null : author.name"
class="mr-widget-author"
>
<gl-link v-gl-tooltip :href="authorUrl" :title="showAuthorName ? null : author.name">
<gl-avatar :src="avatarUrl" :size="16" :alt="author.name" /><span
v-if="showAuthorName"
class="author gl-ml-2"

View File

@ -79,7 +79,7 @@ export default {
};
</script>
<template>
<state-container status="closed" :actions="actions" is-collapsible>
<state-container status="closed" :actions="actions">
<mr-widget-author-time
:action-text="s__('mrWidget|Closed by')"
:author="mr.metrics.closedBy"

View File

@ -79,7 +79,8 @@ export default {
return label.name || label.title;
},
updateListOfAllLabels() {
this.labels.forEach((label) => {
const dedupedLabels = this.dedupeLabels(this.labels);
dedupedLabels.forEach((label) => {
if (!this.findLabelById(label.id)) {
this.allLabels.push(label);
}
@ -93,7 +94,8 @@ export default {
// We'd want to avoid doing this check but
// labels.json and /groups/:id/labels & /projects/:id/labels
// return response differently.
this.labels = Array.isArray(res) ? res : res.data;
const rawLabels = Array.isArray(res) ? res : res.data;
this.labels = this.dedupeLabels(rawLabels);
this.updateListOfAllLabels();
if (this.config.fetchLatestLabels) {
@ -116,7 +118,8 @@ export default {
// We'd want to avoid doing this check but
// labels.json and /groups/:id/labels & /projects/:id/labels
// return response differently.
this.labels = Array.isArray(res) ? res : res.data;
const rawLabels = Array.isArray(res) ? res : res.data;
this.labels = this.dedupeLabels(rawLabels);
this.updateListOfAllLabels();
})
.catch(() =>
@ -125,6 +128,17 @@ export default {
}),
);
},
dedupeLabels(labels) {
const seen = new Set();
return labels.filter((label) => {
const labelName = this.getLabelName(label);
if (seen.has(labelName)) {
return false;
}
seen.add(labelName);
return true;
});
},
},
};
</script>

View File

@ -9,6 +9,11 @@ export default {
GlCollapsibleListbox,
},
props: {
inputId: {
type: String,
required: false,
default: 'user_timezone',
},
headerText: {
type: String,
required: false,
@ -108,7 +113,7 @@ export default {
<div class="gl-relative">
<input
v-if="name"
id="user_timezone"
:id="inputId"
:name="name"
:value="timezoneIdentifier || value"
:required="required"

View File

@ -32,6 +32,11 @@ export default {
required: false,
default: undefined,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
@ -184,6 +189,7 @@ export default {
:searching="isLoading"
:selected="selectedId"
:toggle-text="toggleText"
:disabled="disabled"
@reset="reset"
@search="setSearchTermDebounced"
@select="handleSelect"

View File

@ -24,6 +24,11 @@ export default {
required: false,
default: undefined,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
toggleText() {
@ -51,6 +56,7 @@ export default {
:reset-button-label="__('Reset')"
:selected="value"
:toggle-text="toggleText"
:disabled="disabled"
@reset="reset"
@select="$emit('input', $event)"
/>

View File

@ -39,6 +39,11 @@ export default {
required: false,
default: () => [],
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
@ -177,6 +182,7 @@ export default {
:searching="isLoading"
:selected="selectedIds"
:toggle-text="toggleText"
:disabled="disabled"
@reset="handleSelect([])"
@search="setSearchTermDebounced"
@select="handleSelect"

View File

@ -28,6 +28,11 @@ export default {
required: false,
default: undefined,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
@ -142,6 +147,7 @@ export default {
:searching="isLoading"
:selected="selectedId"
:toggle-text="toggleText"
:disabled="disabled"
@reset="reset"
@search="setSearchTermDebounced"
@select="handleSelect"

View File

@ -30,6 +30,11 @@ export default {
required: false,
default: undefined,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
@ -172,6 +177,7 @@ export default {
:searching="isLoading"
:selected="selectedId"
:toggle-text="toggleText"
:disabled="disabled"
@reset="reset"
@search="setSearchTermDebounced"
@select="handleSelect"

View File

@ -6,8 +6,17 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
import { __, s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { BULK_UPDATE_UNASSIGNED } from '../../constants';
import {
BULK_UPDATE_UNASSIGNED,
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_HEALTH_STATUS,
WIDGET_TYPE_HIERARCHY,
WIDGET_TYPE_ITERATION,
WIDGET_TYPE_LABELS,
WIDGET_TYPE_MILESTONE,
} from '../../constants';
import workItemBulkUpdateMutation from '../../graphql/list/work_item_bulk_update.mutation.graphql';
import getAvailableBulkEditWidgets from '../../graphql/list/get_available_bulk_edit_widgets.query.graphql';
import workItemParent from '../../graphql/list/work_item_parent.query.graphql';
import WorkItemBulkEditAssignee from './work_item_bulk_edit_assignee.vue';
import WorkItemBulkEditDropdown from './work_item_bulk_edit_dropdown.vue';
@ -70,6 +79,7 @@ export default {
},
data() {
return {
availableWidgets: [],
addLabelIds: [],
assigneeId: undefined,
confidentiality: undefined,
@ -100,6 +110,21 @@ export default {
return !this.shouldUseGraphQLBulkEdit;
},
},
availableWidgets: {
query: getAvailableBulkEditWidgets,
variables() {
return {
fullPath: this.fullPath,
ids: this.workItemTypeIds,
};
},
update(data) {
return data.namespace?.workItemsWidgets || [];
},
skip() {
return this.checkedItems.length === 0;
},
},
},
computed: {
legacyBulkEditEndpoint() {
@ -113,6 +138,30 @@ export default {
isEditableUnlessEpicList() {
return !this.shouldUseGraphQLBulkEdit || (this.shouldUseGraphQLBulkEdit && !this.isEpicsList);
},
workItemTypeIds() {
return [...new Set(this.checkedItems.map((item) => item.workItemType.id))];
},
hasItemsSelected() {
return this.checkedItems.length > 0;
},
canEditAssignees() {
return this.availableWidgets.includes(WIDGET_TYPE_ASSIGNEES);
},
canEditLabels() {
return this.availableWidgets.includes(WIDGET_TYPE_LABELS);
},
canEditHealthStatus() {
return this.availableWidgets.includes(WIDGET_TYPE_HEALTH_STATUS);
},
canEditIteration() {
return this.availableWidgets.includes(WIDGET_TYPE_ITERATION);
},
canEditMilestone() {
return this.availableWidgets.includes(WIDGET_TYPE_MILESTONE);
},
canEditParent() {
return this.availableWidgets.includes(WIDGET_TYPE_HIERARCHY);
},
},
methods: {
async handleFormSubmitted() {
@ -208,6 +257,7 @@ export default {
:header-text="__('Select state')"
:items="$options.stateItems"
:label="__('State')"
:disabled="!hasItemsSelected"
data-testid="bulk-edit-state"
/>
<work-item-bulk-edit-assignee
@ -215,12 +265,14 @@ export default {
v-model="assigneeId"
:full-path="fullPath"
:is-group="isGroup"
:disabled="!hasItemsSelected || !canEditAssignees"
/>
<work-item-bulk-edit-labels
:form-label="__('Add labels')"
:full-path="fullPath"
:is-group="isGroup"
:selected-labels-ids="addLabelIds"
:disabled="!hasItemsSelected || !canEditLabels"
data-testid="bulk-edit-add-labels"
@select="addLabelIds = $event"
/>
@ -230,6 +282,7 @@ export default {
:full-path="fullPath"
:is-group="isGroup"
:selected-labels-ids="removeLabelIds"
:disabled="!hasItemsSelected || !canEditLabels"
data-testid="bulk-edit-remove-labels"
@select="removeLabelIds = $event"
/>
@ -239,6 +292,7 @@ export default {
:header-text="__('Select health status')"
:items="$options.healthStatusItems"
:label="__('Health status')"
:disabled="!hasItemsSelected || !canEditHealthStatus"
data-testid="bulk-edit-health-status"
/>
<work-item-bulk-edit-dropdown
@ -247,6 +301,7 @@ export default {
:header-text="__('Select subscription')"
:items="$options.subscriptionItems"
:label="__('Subscription')"
:disabled="!hasItemsSelected"
data-testid="bulk-edit-subscription"
/>
<work-item-bulk-edit-dropdown
@ -255,6 +310,7 @@ export default {
:header-text="__('Select confidentiality')"
:items="$options.confidentialityItems"
:label="__('Confidentiality')"
:disabled="!hasItemsSelected"
data-testid="bulk-edit-confidentiality"
/>
<work-item-bulk-edit-iteration
@ -262,18 +318,21 @@ export default {
v-model="iterationId"
:full-path="fullPath"
:is-group="isGroup"
:disabled="!hasItemsSelected || !canEditIteration"
/>
<work-item-bulk-edit-milestone
v-if="shouldUseGraphQLBulkEdit && !isEpicsList"
v-model="milestoneId"
:full-path="fullPath"
:is-group="isGroup"
:disabled="!hasItemsSelected || !canEditMilestone"
/>
<work-item-bulk-edit-parent
v-if="shouldUseGraphQLBulkEdit && !isEpicsList"
v-model="parentId"
:full-path="fullPath"
:is-group="isGroup"
:disabled="!hasItemsSelected || !canEditParent"
/>
</gl-form>
</template>

View File

@ -0,0 +1,6 @@
query getAvailableBulkEditWidgets($fullPath: ID!, $ids: [WorkItemsTypeID!]!) {
namespace(fullPath: $fullPath) {
id
workItemsWidgets(ids: $ids)
}
}

View File

@ -854,8 +854,7 @@ $diff-file-header-top: 11px;
}
.mr-ready-merge-related-links a,
.mr-widget-merge-details a,
.mr-widget-author {
.mr-widget-merge-details a {
text-decoration: underline;
&:hover,

View File

@ -3,8 +3,8 @@ name: admin_groups_vue
description: Render Admin area -> Groups with Vue instead of HAML/vanilla JS
feature_issue_url: https://gitlab.com/groups/gitlab-org/-/epics/17783
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/194642
rollout_issue_url:
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/553229
milestone: '18.2'
group: group::organizations
type: wip
type: beta
default_enabled: false

View File

@ -103,7 +103,10 @@ To add a broadcast message:
group, subgroup, and project pages, but does not display in Git remote responses.
1. If required, select the **Target roles** to show the broadcast message to.
1. If required, add a **Target Path** to only show the broadcast message on URLs matching that path.
Use the wildcard character `*` to match multiple URLs, like `mygroup/myproject*`.
Use the wildcard character `*` to match multiple URLs and specify paths, for example:
- `*/-/milestones` for any group or project's **Milestones** index page.
- `*/-/milestones/*` for individual milestone pages only.
- `*/-/milestones*` for both index and individual milestone pages.
1. Select a date and time (UTC) for the message to start and end.
1. Select **Add broadcast message**.

View File

@ -103,6 +103,7 @@ For more information, see [epic 12978](https://gitlab.com/groups/gitlab-org/-/ep
{{< history >}}
- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/513252) in GitLab 18.1 [with a flag](../../administration/feature_flags/_index.md) named `duo_rca_usage_rate`. Disabled by default.
- [Enabled on GitLab.com, GitLab Self-Managed, and GitLab Dedicated](https://gitlab.com/gitlab-org/gitlab/-/issues/543987) in GitLab 18.3.
{{< /history >}}
@ -110,7 +111,6 @@ For more information, see [epic 12978](https://gitlab.com/groups/gitlab-org/-/ep
The availability of this feature is controlled by a feature flag.
For more information, see the history.
This feature is available for testing.
{{< /alert >}}

View File

@ -1,6 +1,6 @@
---
stage: Application Security Testing
group: Static Analysis
stage: Security Risk Management
group: Security Platform Management
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
title: Security configuration
description: Configuration, testing, compliance, scanning, and enablement.

View File

@ -18,7 +18,7 @@ PATH
faraday (~> 2)
google-cloud-storage_transfer (~> 1.2.0)
google-protobuf (~> 3.25, >= 3.25.3)
googleauth (~> 1.8.1)
googleauth (~> 1.14)
grpc (= 1.63.0)
json (~> 2.7)
jwt (~> 2.5)
@ -88,6 +88,7 @@ GEM
google-cloud-storage_transfer-v1 (0.8.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-logging-utils (0.1.0)
google-protobuf (3.25.4)
google-protobuf (3.25.4-aarch64-linux)
google-protobuf (3.25.4-arm64-darwin)
@ -100,8 +101,10 @@ GEM
grpc (~> 1.41)
googleapis-common-protos-types (1.15.0)
google-protobuf (>= 3.18, < 5.a)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
googleauth (1.14.0)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
@ -253,6 +256,7 @@ CHECKSUMS
google-cloud-errors (1.4.0) sha256=0b4e2e0f563db1708732ab4037421d9f26de5cbbbc04be710f2c9cf358e2de14
google-cloud-storage_transfer (1.2.0) sha256=132901f50889e02a0d378e6117c6408cbfc4fdbd15c9d31fabec4f4189ef1658
google-cloud-storage_transfer-v1 (0.8.0) sha256=9dbef80275db556e046bb24139ca6559affe641d1e38b2537b8caaf2f8896176
google-logging-utils (0.1.0) sha256=70950b1e49314273cf2e167adb47b62af7917a4691b580da7e9be67b9205fcd5
google-protobuf (3.25.4) sha256=a1c594ca9d99c894e558f984d70731a8935ec639e75865f0181cab126a0aef0e
google-protobuf (3.25.4-aarch64-linux) sha256=d155538358d03af4bcac908811d2c8b287573005f0549d8cf55354ad0c0928ff
google-protobuf (3.25.4-arm64-darwin) sha256=6d39a99a7910fc6b03479c298f38be9497938f78c0f08c89d7542bc8205be8c7
@ -261,7 +265,7 @@ CHECKSUMS
google-protobuf (3.25.4-x86_64-linux) sha256=9e8e66fb5a00cf90f88f37b07e7da10ca9e176e28a3314fc80c4e7fdab120aeb
googleapis-common-protos (1.6.0) sha256=d540114a75fd4b34fee936495d28ff7e331d546b7d7ac7898f3b4bb9f13a8d79
googleapis-common-protos-types (1.15.0) sha256=57b1600c271fa3312096e55a3040d20d2c0f9a5d65d0fde1f16e5cd99bab156b
googleauth (1.8.1) sha256=814adadaaa1221dce72a67131e3ecbd6d23491a161ec84fb15fd353b87d8c9e7
googleauth (1.14.0) sha256=62e7de11791890c3d3dc70582dfd9ab5516530e4e4f56d96451fd62c76475149
grpc (1.63.0) sha256=5f4383c4ee2886e92c31b90422261b7527f26e3baa585d877e9804e715983686
grpc (1.63.0-aarch64-linux) sha256=dc75c5fd570b819470781d9512105dddfdd11d984f38b8e60bb946f92d1f79ee
grpc (1.63.0-arm64-darwin) sha256=91b93a354508a9d1772f095554f2e4c04358c2b32d7a670e3705b7fc4695c996

View File

@ -27,7 +27,7 @@ Gem::Specification.new do |spec|
spec.add_dependency "activerecord", ">= 7"
spec.add_dependency "activesupport", ">= 7"
spec.add_dependency "bigdecimal", "~> 3.1"
spec.add_dependency "googleauth", "~> 1.8.1" # https://gitlab.com/gitlab-org/gitlab/-/issues/449019
spec.add_dependency "googleauth", "~> 1.14"
spec.add_dependency "google-cloud-storage_transfer", "~> 1.2.0"
spec.add_dependency "mutex_m", "~> 0.3"
spec.add_dependency "pg", "~> 1.5.6"

View File

@ -55,7 +55,9 @@ module QA
Page::Main::Login.perform do |login|
login.sign_in_using_credentials(user: admin_user)
rescue Runtime::User::ExpiredPasswordError
login.set_up_new_password(user: admin_user)
Support::Retrier.retry_until(retry_on_exception: true, message: "set_up_new_password failed") do
login.set_up_new_password(user: admin_user)
end
end
Page::Main::Menu.perform(&:sign_out_if_signed_in)

View File

@ -192,6 +192,10 @@ module QA
password = user.password
new_password_page.set_new_password(password, password)
end
Support::Waiter.wait_until(message: "on_login_page? failed") do
Page::Main::Login.perform(&:on_login_page?)
end
end
private

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
# This script deletes all projects directly under all 'gitlab-e2e-sandbox-group-<#0-7>' groups OR a group specified by
# This script deletes all projects directly under all 'gitlab-e2e-sandbox-group-<#1-8>' groups OR a group specified by
# ENV['TOP_LEVEL_GROUP_NAME']
# - If `dry_run` is true the script will list projects to be deleted, but it won't delete them
@ -15,18 +15,18 @@
# - Set PERMANENTLY_DELETE to true if you would like to permanently delete subgroups on an environment with
# deletion protection enabled. Otherwise, subgroups will remain available during the retention period specified
# in admin settings. On environments with deletion protection disabled, subgroups will always be permanently deleted.
# - Set DELETE_BEFORE to only delete projects that were created before a given date, otherwise defaults to 2 hours ago
# - Set DELETE_BEFORE to only delete projects that were created before a given date, otherwise defaults to 24 hours ago
# Run `rake delete_projects`
module QA
module Tools
class DeleteProjects < DeleteResourceBase
# @example mark projects for deletion that are older than 2 hours under all gitlab-e2e-sandbox-group-<#0-7> groups
# @example mark projects for deletion that are older than 24 hours under gitlab-e2e-sandbox-group-<#1-8> groups
# GITLAB_ADDRESS=<address> \
# GITLAB_QA_ACCESS_TOKEN=<token> bundle exec rake delete_projects
#
# @example permanently delete projects older than 2 hours under all gitlab-e2e-sandbox-group-<#0-7> groups
# @example permanently delete projects older than 24 hours under all gitlab-e2e-sandbox-group-<#1-8> groups
# GITLAB_ADDRESS=<address> \
# GITLAB_QA_ACCESS_TOKEN=<token> \
# PERMANENTLY_DELETE=true bundle exec rake delete_projects

View File

@ -12,14 +12,14 @@ module QA
ITEMS_PER_PAGE = '100'
PAGE_CUTOFF = '10'
SANDBOX_GROUPS = %w[gitlab-e2e-sandbox-group-0
gitlab-e2e-sandbox-group-1
SANDBOX_GROUPS = %w[gitlab-e2e-sandbox-group-1
gitlab-e2e-sandbox-group-2
gitlab-e2e-sandbox-group-3
gitlab-e2e-sandbox-group-4
gitlab-e2e-sandbox-group-5
gitlab-e2e-sandbox-group-6
gitlab-e2e-sandbox-group-7].freeze
gitlab-e2e-sandbox-group-7
gitlab-e2e-sandbox-group-8].freeze
def initialize(dry_run: false)
%w[GITLAB_ADDRESS GITLAB_QA_ACCESS_TOKEN].each do |var|
@ -28,7 +28,7 @@ module QA
@api_client = Runtime::API::Client.new(ENV['GITLAB_ADDRESS'],
personal_access_token: ENV['GITLAB_QA_ACCESS_TOKEN'])
@delete_before = Time.parse(ENV['DELETE_BEFORE'] || (Time.now - (2 * 3600)).to_s).utc.iso8601(3)
@delete_before = Time.parse(ENV['DELETE_BEFORE'] || (Time.now - (24 * 3600)).to_s).utc.iso8601(3)
@dry_run = dry_run
@permanently_delete = !!(ENV['PERMANENTLY_DELETE'].to_s =~ /true|1|y/i)
@type = nil

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
# This script deletes all subgroups of all 'gitlab-e2e-sandbox-group-<#0-7>' groups OR all subgroups of a group
# This script deletes all subgroups of all 'gitlab-e2e-sandbox-group-<#1-8>' groups OR all subgroups of a group
# specified by ENV['TOP_LEVEL_GROUP_NAME']
# - If `dry_run` is true the script will list subgroups to be deleted, but it won't delete them
@ -10,22 +10,22 @@
# PERMANENTLY_DELETE (default: false),
# DELETE_BEFORE - YYYY-MM-DD, YYYY-MM-DD HH:MM:SS, or YYYY-MM-DDT00:00:00Z
# - Set TOP_LEVEL_GROUP_NAME to only delete subgroups under the given group.
# If not set, subgroups of all 'gitlab-e2e-sandbox-group-<#0-7>' groups will be deleted.
# If not set, subgroups of all 'gitlab-e2e-sandbox-group-<#1-8>' groups will be deleted.
# - Set PERMANENTLY_DELETE to true if you would like to permanently delete subgroups on an environment with
# deletion protection enabled. Otherwise, subgroups will remain available during the retention period specified
# in admin settings. On environments with deletion protection disabled, subgroups will always be permanently deleted.
# - Set DELETE_BEFORE to only delete snippets that were created before a given date, otherwise defaults to 2 hours ago
# - Set DELETE_BEFORE to only delete snippets that were created before a given date, otherwise defaults to 24 hours ago
# Run `rake delete_subgroups`
module QA
module Tools
class DeleteSubgroups < DeleteResourceBase
# @example mark subgroups for deletion that are older than 2 hours under all gitlab-e2e-sandbox-group-<#0-7> groups
# @example mark subgroups for deletion that are older than 24 hours under all gitlab-e2e-sandbox-group-<#1-8> groups
# GITLAB_ADDRESS=<address> \
# GITLAB_QA_ACCESS_TOKEN=<token> bundle exec rake delete_subgroups
#
# @example permanently delete subgroups older than 2 hours under all gitlab-e2e-sandbox-group-<#0-7> groups
# @example permanently delete subgroups older than 24 hours under all gitlab-e2e-sandbox-group-<#1-8> groups
# GITLAB_ADDRESS=<address> \
# GITLAB_QA_ACCESS_TOKEN=<token> \
# PERMANENTLY_DELETE=true bundle exec rake delete_subgroups

View File

@ -7,14 +7,14 @@
# - GITLAB_QA_ACCESS_TOKEN should have API access and belong to the user whose snippets will be deleted
# Optional environment variables: DELETE_BEFORE - YYYY-MM-DD, YYYY-MM-DD HH:MM:SS, or YYYY-MM-DDT00:00:00Z
# - Set DELETE_BEFORE to only delete snippets that were created before a given date, otherwise defaults to 2 hours ago
# - Set DELETE_BEFORE to only delete snippets that were created before a given date, otherwise default is 24 hours ago
# Run `rake delete_test_snippets`
module QA
module Tools
class DeleteTestSnippets < DeleteResourceBase
# @example delete snippets older than 2 hours for the user associated with the given access token
# @example delete snippets older than 24 hours for the user associated with the given access token
# GITLAB_ADDRESS=<address> \
# GITLAB_QA_ACCESS_TOKEN=<token> bundle exec rake delete_test_snippets
#

View File

@ -9,7 +9,7 @@
# - GITLAB_QA_ACCESS_TOKEN should have API access and belong to the user whose keys will be deleted
# Optional environment variables: DELETE_BEFORE - YYYY-MM-DD, YYYY-MM-DD HH:MM:SS, or YYYY-MM-DDT00:00:00Z
# - Set DELETE_BEFORE to only delete snippets that were created before a given date, otherwise defaults to 2 hours ago
# - Set DELETE_BEFORE to only delete snippets that were created before a given date, otherwise default is 24 hours ago
# Run `rake delete_test_ssh_keys`

View File

@ -8,7 +8,7 @@
# - GITLAB_QA_ADMIN_ACCESS_TOKEN must have admin API access
# Optional environment variables: DELETE_BEFORE - YYYY-MM-DD, YYYY-MM-DD HH:MM:SS, or YYYY-MM-DDT00:00:00Z
# - Set DELETE_BEFORE to only delete users that were created before a given date, otherwise defaults to 2 hours ago
# - Set DELETE_BEFORE to only delete users that were created before a given date, otherwise defaults to 24 hours ago
# Run `rake delete_test_users`

View File

@ -5,21 +5,21 @@
# Required environment variables: GITLAB_QA_ACCESS_TOKEN, GITLAB_ADDRESS
# Optional environment variables: DELETE_BEFORE - YYYY-MM-DD, YYYY-MM-DD HH:MM:SS, or YYYY-MM-DDT00:00:00Z
# - Set DELETE_BEFORE to delete only groups that were created before the given date (default: 2 hours ago)
# - Set DELETE_BEFORE to delete only groups that were created before the given date (default: 24 hours ago)
# Run `rake delete_user_groups`
module QA
module Tools
class DeleteUserGroups < DeleteResourceBase
EXCLUDE_GROUPS = %w[gitlab-e2e-sandbox-group-0
gitlab-e2e-sandbox-group-1
EXCLUDE_GROUPS = %w[gitlab-e2e-sandbox-group-1
gitlab-e2e-sandbox-group-2
gitlab-e2e-sandbox-group-3
gitlab-e2e-sandbox-group-4
gitlab-e2e-sandbox-group-5
gitlab-e2e-sandbox-group-6
gitlab-e2e-sandbox-group-7
gitlab-e2e-sandbox-group-8
quality-e2e-tests
quality-e2e-tests-2
quality-e2e-tests-3
@ -31,7 +31,7 @@ module QA
qa-perf-testing
remote-development].freeze
# @example - delete user groups older than 2 hours
# @example - delete user groups older than 24 hours
# GITLAB_ADDRESS=<address> \
# GITLAB_QA_ACCESS_TOKEN=<token> \
# bundle exec rake delete_user_groups

View File

@ -9,7 +9,7 @@
# - USER_ID to the id of the user whose projects are to be deleted.
# Optional environment variables: DELETE_BEFORE - YYYY-MM-DD, YYYY-MM-DD HH:MM:SS, or YYYY-MM-DDT00:00:00Z
# - Set DELETE_BEFORE to delete only projects that were created before the given date (default: 2 hours ago)
# - Set DELETE_BEFORE to delete only projects that were created before the given date (default: 24 hours ago)
# Run `rake delete_user_projects`
@ -26,7 +26,7 @@ module QA
gitlab-qa-user5
gitlab-qa-user6].freeze
# @example - delete the given users projects older than 2 hours
# @example - delete the given users projects older than 24 hours
# GITLAB_ADDRESS=<address> \
# GITLAB_QA_ACCESS_TOKEN=<token> \
# USER_ID=<id> bundle exec rake delete_user_projects

View File

@ -14,7 +14,7 @@ import { globalAccessorPlugin } from '~/pinia/plugins';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { useNotes } from '~/notes/store/legacy_notes';
import { useBatchComments } from '~/batch_comments/store';
import { noteableDataMock, notesDataMock, discussionMock, note } from '../mock_data';
import { noteableDataMock, notesDataMock, discussionMock } from '../mock_data';
jest.mock('~/lib/utils/autosave');
@ -138,15 +138,15 @@ describe('issue_note_form component', () => {
});
it.each`
internal | placeholder
${false} | ${'Write a comment or drag your files here…'}
${true} | ${'Write an internal note or drag your files here…'}
confidential | placeholder
${false} | ${'Write a comment or drag your files here…'}
${true} | ${'Write an internal note or drag your files here…'}
`(
'should set correct textarea placeholder text when discussion confidentiality is $internal',
async ({ internal, placeholder }) => {
props.note = {
...note,
internal,
async ({ confidential, placeholder }) => {
props.discussion = {
...discussionMock,
confidential,
};
createComponentWrapper();
@ -244,9 +244,9 @@ describe('issue_note_form component', () => {
});
});
describe('when discussion is internal', () => {
describe('when discussion is confidential', () => {
beforeEach(() => {
createComponentWrapper({ note: { internal: true } });
createComponentWrapper({ discussion: { confidential: true } });
});
it('passes correct internal note information to CommentFieldLayout', () => {

View File

@ -143,6 +143,31 @@ describe('LabelToken', () => {
it('sets `loading` to false when request completes', () => {
expect(findBaseToken().props('suggestionsLoading')).toBe(false);
});
it('only shows each label title once', async () => {
const duplicateLabels = [
{ id: 1, title: 'Example', color: '#FF0000', textColor: '#FFFFFF' },
{ id: 2, title: 'Example', color: '#00FF00', textColor: '#000000' },
{ id: 3, title: 'Unique Label', color: '#0000FF', textColor: '#FFFFFF' },
];
wrapper = createComponent({
config: {
fetchLabels: jest.fn().mockResolvedValue({ data: duplicateLabels }),
},
});
await triggerFetchLabels(searchTerm);
const suggestions = findBaseToken().props('suggestions');
const labelTitles = suggestions.map((label) => label.title);
// Should only have one "Example" label
expect(labelTitles.filter((title) => title === 'Example')).toHaveLength(1);
// Should still have the unique label
expect(labelTitles).toContain('Unique Label');
// Total count should be 2 (one deduplicated "Example" + one "Unique Label")
expect(suggestions).toHaveLength(2);
});
});
describe('when request fails', () => {

View File

@ -15,35 +15,73 @@ import WorkItemBulkEditParent from '~/work_items/components/work_item_bulk_edit/
import WorkItemBulkEditSidebar from '~/work_items/components/work_item_bulk_edit/work_item_bulk_edit_sidebar.vue';
import workItemBulkUpdateMutation from '~/work_items/graphql/list/work_item_bulk_update.mutation.graphql';
import workItemParentQuery from '~/work_items/graphql/list//work_item_parent.query.graphql';
import { workItemParentQueryResponse } from '../../mock_data';
import getAvailableBulkEditWidgets from '~/work_items/graphql/list/get_available_bulk_edit_widgets.query.graphql';
import {
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_HEALTH_STATUS,
WIDGET_TYPE_HIERARCHY,
WIDGET_TYPE_LABELS,
WIDGET_TYPE_MILESTONE,
} from '~/work_items/constants';
import {
workItemParentQueryResponse,
availableBulkEditWidgetsQueryResponse,
} from '../../mock_data';
jest.mock('~/alert');
Vue.use(VueApollo);
const availableWidgetsWithout = (widgetToExclude) => {
const widgetNames = availableBulkEditWidgetsQueryResponse.data.namespace.workItemsWidgets.filter(
(name) => name !== widgetToExclude,
);
return {
data: {
namespace: {
...availableBulkEditWidgetsQueryResponse.data.namespace,
workItemsWidgets: widgetNames,
},
},
};
};
describe('WorkItemBulkEditSidebar component', () => {
let axiosMock;
let wrapper;
const checkedItems = [
{ id: 'gid://gitlab/WorkItem/11', title: 'Work Item 11' },
{ id: 'gid://gitlab/WorkItem/22', title: 'Work Item 22' },
{
id: 'gid://gitlab/WorkItem/11',
title: 'Work Item 11',
workItemType: { id: 'gid://gitlab/WorkItems::Type/8' },
},
{
id: 'gid://gitlab/WorkItem/22',
title: 'Work Item 22',
workItemType: { id: 'gid://gitlab/WorkItems::Type/5' },
},
];
const workItemParentQueryHandler = jest.fn().mockResolvedValue(workItemParentQueryResponse);
const workItemBulkUpdateHandler = jest
.fn()
.mockResolvedValue({ data: { workItemBulkUpdate: { updatedWorkItemCount: 1 } } });
const defaultAvailableWidgetsHandler = jest
.fn()
.mockResolvedValue(availableBulkEditWidgetsQueryResponse);
const createComponent = ({
provide = {},
props = {},
mutationHandler = workItemBulkUpdateHandler,
availableWidgetsHandler = defaultAvailableWidgetsHandler,
} = {}) => {
wrapper = shallowMountExtended(WorkItemBulkEditSidebar, {
apolloProvider: createMockApollo([
[workItemParentQuery, workItemParentQueryHandler],
[workItemBulkUpdateMutation, mutationHandler],
[getAvailableBulkEditWidgets, availableWidgetsHandler],
]),
provide: {
hasIssuableHealthStatusFeature: false,
@ -270,6 +308,35 @@ describe('WorkItemBulkEditSidebar component', () => {
});
});
describe('getAvailableBulkEditWidgets query', () => {
beforeEach(() => {
createComponent({ provide: { glFeatures: { workItemsBulkEdit: true } } });
});
it('is called when mounted', () => {
expect(defaultAvailableWidgetsHandler).toHaveBeenCalled();
});
it('is called when checkedItems is updated and there is a new work item type', async () => {
await wrapper.setProps({
checkedItems: [
...checkedItems,
{
id: 'gid://gitlab/WorkItem/14',
title: 'Work Item 14',
workItemType: { id: 'gid://gitlab/WorkItems::Type/9' },
},
],
});
await nextTick();
await waitForPromises();
// once on initial mount, once when checked items change
expect(defaultAvailableWidgetsHandler).toHaveBeenCalledTimes(2);
});
});
describe('workItemParent query', () => {
it('is called when isEpicsList=true', () => {
createComponent({ props: { isEpicsList: true } });
@ -316,6 +383,41 @@ describe('WorkItemBulkEditSidebar component', () => {
expect(findAssigneeComponent().props('value')).toBe('gid://gitlab/User/5');
});
it('enables "Assignee" component when "Assignees" widget is available', async () => {
createComponent({
provide: {
glFeatures: {
workItemsBulkEdit: true,
},
},
props: { isEpicsList: false },
});
await nextTick();
await waitForPromises();
expect(findAssigneeComponent().props('disabled')).toBe(false);
});
it('disables "Assignee" component when "Assignees" widget is unavailable', async () => {
createComponent({
provide: {
glFeatures: {
workItemsBulkEdit: true,
},
},
props: { isEpicsList: false },
availableWidgetsHandler: jest
.fn()
.mockResolvedValue(availableWidgetsWithout(WIDGET_TYPE_ASSIGNEES)),
});
await nextTick();
await waitForPromises();
expect(findAssigneeComponent().props('disabled')).toBe(true);
});
});
describe('"Add labels" component', () => {
@ -335,6 +437,41 @@ describe('WorkItemBulkEditSidebar component', () => {
expect(findAddLabelsComponent().props('selectedLabelsIds')).toEqual(labelIds);
});
it('enables "Add labels" component when "Labels" widget is available', async () => {
createComponent({
provide: {
glFeatures: {
workItemsBulkEdit: true,
},
},
props: { isEpicsList: false },
});
await nextTick();
await waitForPromises();
expect(findAddLabelsComponent().props('disabled')).toBe(false);
});
it('disables "Add labels" component when "Labels" widget is unavailable', async () => {
createComponent({
provide: {
glFeatures: {
workItemsBulkEdit: true,
},
},
props: { isEpicsList: false },
availableWidgetsHandler: jest
.fn()
.mockResolvedValue(availableWidgetsWithout(WIDGET_TYPE_LABELS)),
});
await nextTick();
await waitForPromises();
expect(findAddLabelsComponent().props('disabled')).toBe(true);
});
});
describe('"Remove labels" component', () => {
@ -354,6 +491,41 @@ describe('WorkItemBulkEditSidebar component', () => {
expect(findRemoveLabelsComponent().props('selectedLabelsIds')).toEqual(labelIds);
});
it('enables "Remove labels" component when "Labels" widget is available', async () => {
createComponent({
provide: {
glFeatures: {
workItemsBulkEdit: true,
},
},
props: { isEpicsList: false },
});
await nextTick();
await waitForPromises();
expect(findRemoveLabelsComponent().props('disabled')).toBe(false);
});
it('disables "Remove labels" component when "Labels" widget is unavailable', async () => {
createComponent({
provide: {
glFeatures: {
workItemsBulkEdit: true,
},
},
props: { isEpicsList: false },
availableWidgetsHandler: jest
.fn()
.mockResolvedValue(availableWidgetsWithout(WIDGET_TYPE_LABELS)),
});
await nextTick();
await waitForPromises();
expect(findRemoveLabelsComponent().props('disabled')).toBe(true);
});
});
describe('"Health status" component', () => {
@ -380,6 +552,43 @@ describe('WorkItemBulkEditSidebar component', () => {
expect(findHealthStatusComponent().props('value')).toBe('needs_attention');
});
it('enables "Health status" component when "Health status" widget is available', async () => {
createComponent({
provide: {
glFeatures: {
workItemsBulkEdit: true,
},
hasIssuableHealthStatusFeature: true,
},
props: { isEpicsList: false },
});
await nextTick();
await waitForPromises();
expect(findHealthStatusComponent().props('disabled')).toBe(false);
});
it('disables "Health status" component when "Health status" widget is unavailable', async () => {
createComponent({
provide: {
glFeatures: {
workItemsBulkEdit: true,
},
hasIssuableHealthStatusFeature: true,
},
props: { isEpicsList: false },
availableWidgetsHandler: jest
.fn()
.mockResolvedValue(availableWidgetsWithout(WIDGET_TYPE_HEALTH_STATUS)),
});
await nextTick();
await waitForPromises();
expect(findHealthStatusComponent().props('disabled')).toBe(true);
});
});
describe('"Subscription" component', () => {
@ -445,6 +654,41 @@ describe('WorkItemBulkEditSidebar component', () => {
expect(findMilestoneComponent().props('value')).toBe('gid://gitlab/Milestone/30');
});
it('enables "Milestone" component when "Milestone" widget is available', async () => {
createComponent({
provide: {
glFeatures: {
workItemsBulkEdit: true,
},
},
props: { isEpicsList: false },
});
await nextTick();
await waitForPromises();
expect(findMilestoneComponent().props('disabled')).toBe(false);
});
it('disables "Milestone" component when "Milestone" widget is unavailable', async () => {
createComponent({
provide: {
glFeatures: {
workItemsBulkEdit: true,
},
},
props: { isEpicsList: false },
availableWidgetsHandler: jest
.fn()
.mockResolvedValue(availableWidgetsWithout(WIDGET_TYPE_MILESTONE)),
});
await nextTick();
await waitForPromises();
expect(findMilestoneComponent().props('disabled')).toBe(true);
});
});
describe('"Parent" component', () => {
@ -476,5 +720,40 @@ describe('WorkItemBulkEditSidebar component', () => {
expect(findParentComponent().props('value')).toBe('gid://gitlab/WorkItem/30');
});
it('enables "Parent" component when "Hierarchy" widget is available', async () => {
createComponent({
provide: {
glFeatures: {
workItemsBulkEdit: true,
},
},
props: { isEpicsList: false },
});
await nextTick();
await waitForPromises();
expect(findParentComponent().props('disabled')).toBe(false);
});
it('disables "Parent" component when "Hierarchy" widget is unavailable', async () => {
createComponent({
provide: {
glFeatures: {
workItemsBulkEdit: true,
},
},
props: { isEpicsList: false },
availableWidgetsHandler: jest
.fn()
.mockResolvedValue(availableWidgetsWithout(WIDGET_TYPE_HIERARCHY)),
});
await nextTick();
await waitForPromises();
expect(findParentComponent().props('disabled')).toBe(true);
});
});
});

View File

@ -8875,3 +8875,39 @@ export const namespaceWorkItemTypesWithOKRsQueryResponse = {
},
},
};
export const availableBulkEditWidgetsQueryResponse = {
data: {
namespace: {
__typename: 'Namespace',
id: 'gid://gitlab/Namespaces::ProjectNamespace/34',
workItemsWidgets: [
'ASSIGNEES',
'AWARD_EMOJI',
'CRM_CONTACTS',
'CURRENT_USER_TODOS',
'CUSTOM_FIELDS',
'DESCRIPTION',
'DESIGNS',
'DEVELOPMENT',
'EMAIL_PARTICIPANTS',
'ERROR_TRACKING',
'HEALTH_STATUS',
'HIERARCHY',
'ITERATION',
'LABELS',
'LINKED_ITEMS',
'LINKED_RESOURCES',
'MILESTONE',
'NOTES',
'NOTIFICATIONS',
'PARTICIPANTS',
'START_AND_DUE_DATE',
'STATUS',
'TIME_TRACKING',
'VULNERABILITIES',
'WEIGHT',
],
},
},
};

View File

@ -8,13 +8,42 @@ RSpec.describe Tooling::PredictiveTests::MappingFetcher, :aggregate_failures, fe
let(:crystalball_mapping) { "test-mapping.json" }
let(:frontend_fixtures) { "ff_fixtures.json" }
let(:logger) { Logger.new(StringIO.new) }
let(:logger) { Logger.new($stdout, level: :error) }
let(:file_double) { instance_double(File, write: nil) }
let(:http_response) { double("HTTParty::Response", success?: http_success, code: 500, message: "message") } # rubocop:disable RSpec/VerifiedDoubles -- complains about message
let(:open3_status) { instance_double(Process::Status, success?: extract_success) }
let(:file_stat) do
instance_double(File::Stat, size: 123, mtime: Time.parse("Tue, 01 Jan 2024 12:34:56 GMT"))
end
# rubocop:disable RSpec/VerifiedDoubles -- complains about message
let(:get_http_response) do
double(
"HTTParty::Response",
success?: http_success,
code: 500,
message: "message",
headers: { "last-modified" => upstream_last_modified.httpdate }
)
end
let(:head_http_response) do
double(
"HTTPParty::Response",
success?: true,
code: 200,
headers: {
"last-modified" => upstream_last_modified.httpdate,
"content-length" => upstream_content
}
)
end
# rubocop:enable RSpec/VerifiedDoubles
let(:extract_success) { true }
let(:http_success) { true }
let(:upstream_last_modified) { Time.parse("Tue, 02 Jan 2024 12:34:56 GMT") }
let(:upstream_content) { 123 }
let(:unpacked_mapping) { JSON.generate(Tooling::TestMapPacker.new.unpack(JSON.parse(packed_mapping))) } # rubocop:disable Gitlab/Json -- non rails code
let(:packed_mapping) do
@ -41,6 +70,12 @@ RSpec.describe Tooling::PredictiveTests::MappingFetcher, :aggregate_failures, fe
end
before do
allow(described_class).to receive(:head)
.with(
%r{https://gitlab-org.gitlab.io/gitlab/crystalball/(packed-mapping.json.gz|frontend_fixtures_mapping.json)},
timeout: 30
)
.and_return(head_http_response)
allow(described_class).to receive(:get)
.with(
%r{https://gitlab-org.gitlab.io/gitlab/crystalball/(packed-mapping.json.gz|frontend_fixtures_mapping.json)},
@ -48,7 +83,7 @@ RSpec.describe Tooling::PredictiveTests::MappingFetcher, :aggregate_failures, fe
stream_body: true
)
.and_yield("download fragment")
.and_return(http_response)
.and_return(get_http_response)
allow(Open3).to receive(:capture3).with(/gzip -d -c \S+mapping\.gz > \S+/).and_return(["out", "err", open3_status])
# mock file operations only related to mapping fetcher
@ -58,16 +93,25 @@ RSpec.describe Tooling::PredictiveTests::MappingFetcher, :aggregate_failures, fe
allow(File).to receive(:read).with(/mapping\.json/).and_return(packed_mapping)
allow(File).to receive(:write).and_call_original
allow(File).to receive(:write).with(crystalball_mapping, unpacked_mapping)
allow(File).to receive(:exist?).and_call_original
allow(File).to receive(:exist?).with(/mapping\.gz|#{frontend_fixtures}/).and_return(true)
allow(File).to receive(:utime).and_call_original
allow(File).to receive(:utime).with(upstream_last_modified, upstream_last_modified, kind_of(String))
allow(File).to receive(:stat).and_call_original
allow(File).to receive(:stat).with(/mapping\.gz|#{frontend_fixtures}/).and_return(file_stat)
end
it "fetches rspec mappings" do
expect(mapping_fetcher.fetch_rspec_mappings(crystalball_mapping)).to eq(crystalball_mapping)
expect(described_class).to have_received(:get)
expect(file_double).to have_received(:write).with("download fragment")
expect(Open3).to have_received(:capture3).with(/gzip -d -c \S+mapping\.gz > \S+/)
expect(File).to have_received(:write).with(crystalball_mapping, unpacked_mapping)
end
it "fetches frontend fixtures" do
expect(mapping_fetcher.fetch_frontend_fixtures_mappings(frontend_fixtures)).to eq(frontend_fixtures)
expect(described_class).to have_received(:get)
expect(file_double).to have_received(:write).with("download fragment")
end
@ -96,4 +140,18 @@ RSpec.describe Tooling::PredictiveTests::MappingFetcher, :aggregate_failures, fe
)
end
end
context "with files being cached" do
let(:upstream_last_modified) { Time.parse("Tue, 01 Jan 2024 12:34:56 GMT") }
it "skips downloading rspec mapping archive" do
expect(mapping_fetcher.fetch_rspec_mappings(crystalball_mapping)).to eq(crystalball_mapping)
expect(described_class).not_to have_received(:get)
end
it "skips downloading frontend fixtures" do
expect(mapping_fetcher.fetch_frontend_fixtures_mappings(frontend_fixtures)).to eq(frontend_fixtures)
expect(described_class).not_to have_received(:get)
end
end
end

View File

@ -14,8 +14,6 @@ RSpec.describe Tooling::PredictiveTests::MetricsExporter, feature_category: :too
subject(:exporter) do
described_class.new(
rspec_all_failed_tests_file: failed_tests_file,
crystalball_mapping_dir: input_dir,
frontend_fixtures_mapping_file: frontend_fixtures_file,
output_dir: output_dir
)
end
@ -24,6 +22,14 @@ RSpec.describe Tooling::PredictiveTests::MetricsExporter, feature_category: :too
let(:logger) { Logger.new(log_output) }
let(:log_output) { StringIO.new } # useful for debugging to print out all log output
let(:mapping_fetcher) do
instance_double(
Tooling::PredictiveTests::MappingFetcher,
fetch_frontend_fixtures_mappings: nil,
fetch_rspec_mappings: nil
)
end
let(:test_selector_described) do
instance_double(Tooling::PredictiveTests::TestSelector, rspec_spec_list: matching_tests_described_class_specs)
end
@ -39,10 +45,10 @@ RSpec.describe Tooling::PredictiveTests::MetricsExporter, feature_category: :too
let(:input_dir) { Dir.mktmpdir("predictive-tests-input") }
let(:output_dir) { Dir.mktmpdir("predictive-tests-output") }
# various input files used by MetricsExporter to create metrics output
let(:coverage_mapping_file) { File.join(input_dir, "coverage", "mapping.json") }
let(:described_class_mapping_file) { File.join(input_dir, "described_class", "mapping.json") }
let(:failed_tests_file) { File.join(input_dir, "failed_test.txt") }
let(:frontend_fixtures_file) { File.join(input_dir, "frontend_fixtures.json") }
let(:coverage_mapping_file) { File.join(Dir.tmpdir, "coverage", "mapping.json") }
let(:described_class_mapping_file) { File.join(Dir.tmpdir, "described_class", "mapping.json") }
let(:frontend_fixtures_file) { File.join(Dir.tmpdir, "frontend_fixtures_mapping.json") }
# output files created by TestSelector and used by MetricsExporter to create metrics output
let(:matching_tests_coverage_file) { File.join(output_dir, "coverage", "rspec_matching_test_files.txt") }
let(:matching_tests_described_class_file) do
@ -101,10 +107,9 @@ RSpec.describe Tooling::PredictiveTests::MetricsExporter, feature_category: :too
before do
stub_env({ "CI_JOB_ID" => extra_properties[:ci_job_id] })
# create folders for separate strategies
[input_dir, output_dir].each do |dir|
FileUtils.mkdir_p(File.join(dir, "coverage"))
FileUtils.mkdir_p(File.join(dir, "described_class"))
# create folders for mocked input files
[coverage_mapping_file, described_class_mapping_file, failed_tests_file].each do |file|
FileUtils.mkdir_p(File.dirname(file))
end
# create files used as input for exporting selected test metrics
@ -115,6 +120,7 @@ RSpec.describe Tooling::PredictiveTests::MetricsExporter, feature_category: :too
allow(Logger).to receive(:new).with($stdout, progname: "rspec predictive testing").and_return(logger)
allow(Tooling::Events::TrackPipelineEvents).to receive(:new).and_return(event_tracker)
allow(Tooling::PredictiveTests::MappingFetcher).to receive(:new).with(logger: logger).and_return(mapping_fetcher)
allow(Tooling::PredictiveTests::ChangedFiles).to receive(:fetch).with(
frontend_fixtures_file: frontend_fixtures_file
).and_return(changed_files)
@ -133,6 +139,17 @@ RSpec.describe Tooling::PredictiveTests::MetricsExporter, feature_category: :too
end
describe "#execute" do
it "uses mapping fetcher to get mapping json files" do
exporter.execute
expect(mapping_fetcher).to have_received(:fetch_rspec_mappings)
.with(coverage_mapping_file, type: :coverage)
expect(mapping_fetcher).to have_received(:fetch_rspec_mappings)
.with(described_class_mapping_file, type: :described_class)
expect(mapping_fetcher).to have_received(:fetch_frontend_fixtures_mappings)
.with(frontend_fixtures_file)
end
it "exports metrics for described_class strategy", :aggregate_failures do
exporter.execute

View File

@ -19,6 +19,10 @@ options = {}
OptionParser.new do |opts|
opts.banner = "Usage: #{$PROGRAM_NAME} [options]"
opts.on('--ci', 'Use ci specific setup') do
options[:ci] = true
end
opts.on('--select-tests', 'Run test selection logic') do
options[:select_tests] = true
end
@ -39,6 +43,10 @@ OptionParser.new do |opts|
options[:mapping_type] = value
end
opts.on('--changed-files [string]', String, 'Space separated list of changed files. Used outside CI.') do |value|
options[:changed_files] = value
end
opts.on('-h', '--help', 'Show this help message') do
puts opts
exit
@ -71,52 +79,70 @@ if options[:select_tests]
require_relative '../lib/tooling/predictive_tests/changed_files'
require_relative '../lib/tooling/predictive_tests/mapping_fetcher'
validate_required_env_variables!(%w[
RSPEC_MATCHING_TEST_FILES_PATH
FRONTEND_FIXTURES_MAPPING_PATH
RSPEC_MATCHING_JS_FILES_PATH
])
ci = options[:ci]
logger = Logger.new($stdout, progname: '[Predictive Tests]')
if ci
validate_required_env_variables!(%w[
RSPEC_MATCHING_TEST_FILES_PATH
FRONTEND_FIXTURES_MAPPING_PATH
RSPEC_MATCHING_JS_FILES_PATH
])
elsif options[:changed_files].nil? || options[:changed_files].strip.empty?
warn 'Please provide --changed-files with list of changed files for local run.'
exit 1
end
# silence all output on non ci and only output list of spec files to run so it could be used in scripts/hooks
logger = Logger.new($stdout, progname: '[Predictive Tests]', level: ci ? :info : :error)
logger.info("Running predictive test selection")
mapping_fetcher = Tooling::PredictiveTests::MappingFetcher.new(logger: logger)
test_mapping_file = if options[:with_crystalball_mappings]
mapping_fetcher.fetch_rspec_mappings(
'mapping.json',
'tmp/crystalball_mappings.json',
type: options[:mapping_type] || :described_class
)
end
changed_files = Tooling::PredictiveTests::ChangedFiles.fetch(
frontend_fixtures_file: mapping_fetcher.fetch_frontend_fixtures_mappings(ENV['FRONTEND_FIXTURES_MAPPING_PATH'])
)
changed_files = if ci
Tooling::PredictiveTests::ChangedFiles.fetch(
frontend_fixtures_file: mapping_fetcher.fetch_frontend_fixtures_mappings(
ENV['FRONTEND_FIXTURES_MAPPING_PATH']
)
)
else
options[:changed_files].split(" ")
end
test_selector = Tooling::PredictiveTests::TestSelector.new(
changed_files: changed_files,
rspec_test_mapping_path: test_mapping_file,
logger: logger
)
rspec_spec_list = test_selector.rspec_spec_list
js_spec_list = test_selector.js_spec_list
# Used to generate predictive rspec test pipelines
File.write(ENV['RSPEC_MATCHING_TEST_FILES_PATH'], test_selector.rspec_spec_list.join(" "))
# Used by frontend related pipelines/jobs
File.write(ENV['RSPEC_MATCHING_JS_FILES_PATH'], test_selector.js_spec_list.join(" "))
File.write(ENV['RSPEC_CHANGED_FILES_PATH'], changed_files.join("\n"))
if ci
# Used to generate predictive rspec test pipelines
File.write(ENV['RSPEC_MATCHING_TEST_FILES_PATH'], rspec_spec_list.join(" "))
# Used by frontend related pipelines/jobs
File.write(ENV['RSPEC_MATCHING_JS_FILES_PATH'], js_spec_list.join(" "))
File.write(ENV['RSPEC_CHANGED_FILES_PATH'], changed_files.join("\n"))
else
puts (rspec_spec_list + js_spec_list).join(' ')
end
end
if options[:export_rspec_metrics]
require_relative '../lib/tooling/predictive_tests/metrics_exporter'
validate_required_env_variables!(%w[
GLCI_CRYSTALBALL_MAPPING_DIR
GLCI_ALL_FAILED_RSPEC_TESTS_FILE
GLCI_PREDICTIVE_TEST_METRICS_OUTPUT_DIR
])
Tooling::PredictiveTests::MetricsExporter.new(
rspec_all_failed_tests_file: ENV['GLCI_ALL_FAILED_RSPEC_TESTS_FILE'],
crystalball_mapping_dir: ENV['GLCI_CRYSTALBALL_MAPPING_DIR'],
frontend_fixtures_mapping_file: ENV['FRONTEND_FIXTURES_MAPPING_PATH'],
output_dir: ENV['GLCI_PREDICTIVE_TEST_METRICS_OUTPUT_DIR']
).execute
end

View File

@ -6,6 +6,7 @@ require "tmpdir"
require "open3"
require "logger"
require "json"
require "time"
require_relative '../test_map_packer'
@ -30,19 +31,23 @@ module Tooling
end
def fetch_rspec_mappings(unpacked_mapping_file, type: :described_class)
logger.info("Downloading spec mappings of type: #{type}")
logger.info("Fetching spec mappings of type: #{type}")
FileUtils.mkdir_p(File.dirname(unpacked_mapping_file))
mapping_file = MAPPINGS.fetch(type.to_sym) do
logger.warn("No mappings available for type: #{type}, defaulting to described_class")
MAPPINGS[:described_class]
end
mapping_file_archive = File.join(Dir.tmpdir, "mapping.gz")
url = "#{PAGES_URL}/#{mapping_file}.gz"
logger.info("Downloading mapping archive")
download(url, mapping_file_archive) unless skip_download?(url, mapping_file_archive)
# tmpdir ensures all temporary files get deleted
Dir.mktmpdir("test-mappings") do |dir|
mapping_file_archive = File.join(dir, "mapping.gz")
packed_mapping_file = File.join(dir, "mapping.json")
download("#{PAGES_URL}/#{mapping_file}.gz", mapping_file_archive)
logger.info("Creating and unpacking archive")
extract_archive(mapping_file_archive, packed_mapping_file)
unpack(packed_mapping_file, unpacked_mapping_file)
@ -54,7 +59,8 @@ module Tooling
logger.info("Downloading frontend fixtures mappings")
FileUtils.mkdir_p(File.dirname(file_path))
download("#{PAGES_URL}/#{MAPPINGS[:frontend_fixtures]}", file_path)
url = "#{PAGES_URL}/#{MAPPINGS[:frontend_fixtures]}"
download(url, file_path) unless skip_download?(url, file_path)
file_path
end
@ -62,13 +68,62 @@ module Tooling
attr_reader :timeout, :logger
def skip_download?(url, file_path)
upstream_info = upstream_file_info(url)
return false unless upstream_info[:success]
local_info = local_file_info(file_path)
return false unless local_info[:success]
(upstream_info == local_info).tap do |skip|
logger.info("skipping, file exists!") if skip
end
end
def upstream_file_info(url)
response = self.class.head(url, timeout: timeout)
if response.success?
{
success: true,
content_length: response.headers['content-length']&.to_i,
last_modified: response.headers['last-modified']
}
else
{ success: false, error: "HEAD request failed with status #{response.code}" }
end
rescue StandardError => e
logger.warn("Failed to fetch upstream file info: #{e.message}")
{ success: false, error: e.message }
end
def local_file_info(file_path)
return { success: false, error: "File does not exist" } unless File.exist?(file_path)
begin
file_stat = File.stat(file_path)
{
success: true,
content_length: file_stat.size,
last_modified: file_stat.mtime.httpdate
}
rescue StandardError => e
logger.warn("Failed to fetch local file info: #{e.message}")
{ success: false, error: e.message }
end
end
def download(url, destination_path)
logger.debug("Downloading #{url}...")
FileUtils.rm_f(destination_path) # ensure file does not exist since streaming with append mode is used
response = self.class.get(url, timeout: timeout, stream_body: true) do |fragment|
File.open(destination_path, 'ab') { |file| file.write(fragment) }
end
raise "Download failed with status #{response.code}: #{response.message}" unless response.success?
time = Time.parse(response.headers['last-modified'])
File.utime(time, time, destination_path) # preserve original last modified timestamp
logger.debug("Download completed: #{destination_path}")
end

View File

@ -2,11 +2,13 @@
require_relative "test_selector"
require_relative "changed_files"
require_relative "mapping_fetcher"
require_relative "../helpers/file_handler"
require_relative "../events/track_pipeline_events"
require "logger"
require "tmpdir"
module Tooling
module PredictiveTests
@ -21,15 +23,8 @@ module Tooling
STRATEGIES = [:coverage, :described_class].freeze
TEST_TYPE = "backend"
def initialize(
rspec_all_failed_tests_file:,
crystalball_mapping_dir:,
frontend_fixtures_mapping_file:,
output_dir: nil
)
def initialize(rspec_all_failed_tests_file:, output_dir: nil)
@rspec_all_failed_tests_file = rspec_all_failed_tests_file
@crystalball_mapping_dir = crystalball_mapping_dir
@frontend_fixtures_mapping_file = frontend_fixtures_mapping_file
@output_dir = output_dir
@logger = Logger.new($stdout, progname: "rspec predictive testing")
end
@ -49,7 +44,7 @@ module Tooling
private
attr_reader :rspec_all_failed_tests_file, :crystalball_mapping_dir, :frontend_fixtures_mapping_file, :logger
attr_reader :rspec_all_failed_tests_file, :logger
# Project root folder
#
@ -84,12 +79,28 @@ module Tooling
@changed_files ||= ChangedFiles.fetch(frontend_fixtures_file: frontend_fixtures_mapping_file)
end
# Mapping file fetcher
#
# @return [MappingFetcher]
def mapping_fetcher
@mapping_fetcher ||= Tooling::PredictiveTests::MappingFetcher.new(logger: logger)
end
# Frontend fixtures mapping file
#
# @return [String]
def frontend_fixtures_mapping_file
@frontend_fixtures_mapping_file ||= File.join(Dir.tmpdir, "frontend_fixtures_mapping.json").tap do |file|
mapping_fetcher.fetch_frontend_fixtures_mappings(file)
end
end
# Mapping file path for specific strategy
#
# @param strategy [Symbol]
# @return [String]
def mapping_file_path(strategy)
File.join(crystalball_mapping_dir, strategy.to_s, "mapping.json")
File.join(Dir.tmpdir, strategy.to_s, "mapping.json")
end
# Strategy specific matching rspec tests file path
@ -128,6 +139,8 @@ module Tooling
def generate_and_record_metrics(strategy)
logger.info("Generating metrics for mapping strategy '#{strategy}' ...")
# fetch crystalball mappings for specific strategy
fetch_crystalball_mappings!(strategy)
# based on the predictive test selection strategy
predicted_test_files = test_selector(strategy).rspec_spec_list
# actual failed tests from tier-3 run
@ -149,6 +162,14 @@ module Tooling
logger.info("Metrics generation completed for strategy '#{strategy}'")
end
# Fetch crystalball mappings
#
# @param strategy [Symbol]
# @return [void]
def fetch_crystalball_mappings!(strategy)
mapping_fetcher.fetch_rspec_mappings(mapping_file_path(strategy), type: strategy)
end
# Create metrics hash with all calculated metrics based on crystalball mapping and selected test strategy
#
# @param changed_files [Array]